最近在梳理项目,顺手把 DINOv3 和 YOLO26 结合这套方案的做法整理一下。不是"最好"的选择,只是其中一种可行的路径,给需要的朋友一个参考。
工业检测里有种常见场景:图片很大,要找的目标很小。
举个例子,半导体封装里的焊点缺陷检测。PCB整板拍照可能是 8000×6000 的分辨率,但一个焊点缺陷可能只占 3×3 像素。直接用常规检测模型去跑,要么特征图上目标已经缩成了一两个像素根本看不清,要么下采样过程中信息早就丢了。
切片是常见的解决思路,但切分边界可能切断目标。另一种思路是直接用更大分辨率的输入,让目标在特征图上占据更多像素。
这里说的是其中一种:用预训练的视觉大模型提供特征,用成熟的检测头做任务输出。如果输入图片是 1280×1280,一个 3×3 像素的目标在40×40的特征图上大概占 0.6 个像素;但如果输入是 2560×2560,同样目标在特征图上就占 1.2 个像素,信息量翻倍。DINOv3 的特征质量给这种方案提供了新的选择。

核心思路就三步:提特征 → 多尺度化 → 检测输出。
DINOv3是Meta在2025年放出来的自监督模型,用的是ViT架构。它在LVD-1689M这个大规模数据集上训练过,这个规模大概是之前一些方案的几十倍。
我们实际用下来,有几点值得说:
1. 底层特征确实细
自监督学习的损失函数让模型学会了捕捉底层的纹理、边缘信息。这些特征对工业检测来说是有意义的——划痕、凹坑、焊点边缘这些细节,正是缺陷和正常区域的区别所在。
2. 多中间层输出
DINOv3的 get_intermediate_layers 可以取多层输出。不是所有层都同等重要——浅层保留更多底层信息,深层偏语义。在我们的方案里主要用的是第1、2、3层做多尺度,第4层是cls token通常不用。
3. 一个实现细节
def load_dinov3_vit(vit_type="vitb16", pretrained=False, weights=None):
# 优先从本地加载权重,避免重复下载
local_weight_path = "dinov3_vitb16_pretrain_lvd1689m-73cec8be.pth"
if os.path.exists(local_weight_path):
weights = local_weight_path
model = torch.hub.load(
REPO_DIR,
f'dinov3_{vit_type}',
source='local',
weights=weights,
pretrained=pretrained
)
return model
权重文件大概330MB,需要从Meta AI官网申请下载。如果网络不通,可以让同事下好传过来。文件名必须一致,代码里会检查本地路径。
DINOv3输出的是单尺度 40×40 特征图(640输入的情况下),但YOLO检测需要 P3/P4/P5 三个尺度。
def forward(self, x8, x16, x32):
f8 = self.proj8(x8) # 投影到256通道
f16 = self.proj16(x16)
f32 = self.proj32(x32)
# 跨级融合:把高分辨率特征下采样后与低分辨率融合
down_8to16 = avg_pool2d(f8, kernel_size=2, stride=2)
n16 = self.fuse16(cat[f16, down_8to16]) # 512ch
down_16to32 = avg_pool2d(f16, kernel_size=2, stride=2)
n32 = self.fuse32(cat[f32, down_16to32]) # 512ch
return [f8, n16, n32]
这里有个细节:下采样用的是 avg_pool2d 而不是 stride=2 的卷积。原因是平均池化能保留更多空间信息,对小目标检测更友好。
另外当特征图尺寸不完全对齐时(有时候输入图片尺寸不是640的整数倍),需要做个插值:
if down_8to16.shape[-2:] != f16.shape[-2:]:
down_8to16 = F.interpolate(down_8to16, size=f16.shape[-2:], mode='nearest')
这部分是整个方案里最费时间的。
YOLO26的Detect head内部逻辑比较复杂,训练阶段和推理阶段的输出格式完全不同。如果要替换成自己的检测头,需要处理几个兼容问题:
1. 训练时的loss计算
Ultralytics内部计算loss时,期望检测头返回的是 dict 格式:
def forward_head(self, x):
bs = x[0].shape[0]
boxes = cat([self.cv2[i](x[i]).view(bs, 4 * self.reg_max, -1) for i in range(self.nl)], dim=-1)
scores = cat([self.cv3[i](x[i]).view(bs, self.nc, -1) for i in range(self.nl)], dim=-1)
return dict(boxes=boxes, scores=scores, feats=x)
boxes 是 DFL 输出的原始分布,[B, channels, total_anchors]。scores 是每个类别的预测置信度,[B, nc, total_anchors]。feats 是原始特征图,用于生成锚框。
2. 推理时的NMS输入
推理阶段Ultralytics内部会做 NMS,期望检测头输出的是 [B, anchors, 5+nc] 格式的解码后坐标。DINOv3的特征没有先验框信息,需要在检测头里做解码:
def forward(self, x):
if self.training:
return self.forward_head(x)
# 推理时:先 DFL 解码 box,再拼接 class scores
decoded_boxes = []
for i in range(self.nl):
box_raw = x[i][:, :4 * self.reg_max, :, :]
box_decoded = self.dfl(box_raw.view(bs, 4 * self.reg_max, -1))
decoded_boxes.append(box_decoded)
all_boxes = cat([b.contiguous().view(bs, -1, 4) for b in decoded_boxes], dim=1)
all_scores = cat([x[i][:, 4 * self.reg_max:4 * self.reg_max + self.nc, :, :]
for i in range(self.nl)], dim=-1)
return cat([all_boxes, all_scores], dim=-1)
3. 锚框stride的问题
我们的特征图是 40×40、20×20、10×10,对应的stride是 16、32、64,不是标准的 8、16、32。这个要在检测头初始化时指定:
self.stride = torch.tensor([16., 32., 64.])
DFL(Distribution Focal Loss)这个设计在2019年的论文里提的,用来解决边界框回归的问题。核心是把坐标建模成离散的分布,而不是直接回归数值。
在我们这个场景里,设置的是 dfl=1.5(reg_max=16)。
这东西对小目标有效的原因在于:模糊的边界会得到更宽的概率分布,而不是硬给一个确定坐标。比如一个边界不清晰的划痕,如果直接回归坐标,模型会困惑,loss容易震荡;但用DFL的话,模型可以表达"边界大概在这个范围内"的不确定性。
这个参数不一定是最优的,实际项目中可以尝试 1.0 到 2.0 之间的值。
不是所有场景都适用,说几个通用原则:
关于冻结策略
如果数据量少(几百张),可以冻结DINO的大部分层,只训练Neck和检测头。这样能避免破坏预训练特征。
如果数据量够(几千张以上),可以考虑解冻DINO的前两层,让它稍微适应一下工业图像的分布。学习率要小,weight_decay可以设大一些防止破坏预训练权重。
关于学习率
用预训练模型时,学习率一般要比从头训练小一个量级。我们一般用 lr0=0.0001 到 0.0005 这个范围。配合余弦衰减效果更稳。
关于数据增强
工业检测场景如果标注数据不多,建议把 mosaic 和 mixup 关掉或者调小。这些增强对自然图像效果不错,但对工业图像有时候会把缺陷形态破坏掉,反而引入噪声。
产线上不可能跑Python推理,最后导出 ONNX 是常见选择:
model.export(format='onnx', imgsz=[640, 640], dynamic=False, opset=12)
几个注意点:
坦白说,不是所有场景都适合:
适合的场景:
不太适合的场景:
具体还是要看实际场景。可以在小数据集上先做个验证性实验,再决定要不要上。
以上是这套方案的一些整理。有具体问题可以讨论,但不一定能及时回复。如果有更好的思路也欢迎交流。