简介:本资源面向图像分类初学者与迁移学习实践者,提供一套机械图纸三视图abcd四分类的完整可运行方案。主干网络支持resnet、densenet、googleNet三种模型,通过pretrained与freeze_layers参数即可灵活切换是否加载ImageNet预训练权重或仅训练分类输出层;优化器内置Adam与SGD用于对比消融实验,损失函数采用多类别交叉熵,学习率策略为余弦退火。评估环节覆盖训练集与验证集的loss、准确率曲线,并输出混淆矩阵、recall、precision、F1 score及特异度等指标,各类别详细指标存于json文件。资源包共121个文件,含61个png、36个jpg图像样本与结果图、6个py源码、5个json指标文件、3个pth权重及若干txt说明,压缩包约94.45MB,数据集与标签齐备,按参考猫狗数据集摆放即可一键训练。已有64人学习,适合快速复现与二次改进。
1. 机械图纸三视图分类:从 ResNet 到自适应迁移学习的落地路径
机械图纸的三视图识别,说白了就是把一张包含主视图、俯视图、左视图的工程图,自动分到 ABCD 四个类别里。这件事在车间数字化、图纸归档、PLM 系统入库环节是刚需——人工分拣一天几百张图,眼睛看花还容易串号。但直接拿 ResNet 从头训,准确率卡在 70% 上下死活上不去,原因是工业图纸样本太少,一个类别能凑出三百张就算家底厚实。这时候迁移学习就是那根救命稻草:用 ImageNet 预训练模型当起点,再针对三视图的线条、标注、剖视特征做自适应微调。ResNet、DenseNet、GoogleNet 三个骨干网各有脾气,选哪个、怎么冻、怎么解冻、学习率怎么设,直接决定你是三天收工还是三周翻车。这篇把我自己跑过的完整方案拆开,从数据准备到模型融合,每一步都给可复现的命令和参数。
2. 三个骨干网在三视图上的选型逻辑与最小跑通方案
2.1 ResNet、DenseNet、GoogleNet 的特征提取差异
ResNet 靠残差连接把梯度直接传到浅层,在机械图纸上表现最稳。三视图的线条是细粒度特征,ResNet 的 bottleneck 结构在 stage3、stage4 能抓到尺寸标注和剖面线的组合模式。我一般用 ResNet50 起步,它的 7x7 卷积核加 3x3 堆叠,对图纸里粗细不均的轮廓线响应很好。但要注意,ImageNet 预训练的 ResNet 第一层是 7x7 stride 2,对 224x224 输入刚好,如果你的图纸扫描件是 2000x3000 像素,直接 resize 会丢标注文字,得先切图再送网络。
DenseNet 的特点是每一层都跟前面所有层连,特征复用率极高。在样本量少于 500 张每类时,DenseNet121 比 ResNet50 的验证集准确率高 3 到 5 个百分点。原因是三视图里主视图和俯视图的对应关系——比如同一个孔在主视图是圆、在俯视图是虚线——DenseNet 的密集连接能把这些跨视图的弱相关特征反复传递。但代价是显存占用大,batch size 只能开到 ResNet 的一半。
GoogleNet(Inception v3)用多尺度卷积核并行,1x1、3x3、5x5 同时扫。机械图纸里既有大面积的剖面线区域,又有细小的尺寸数字,Inception 模块天然适合这种尺度差异。但 GoogleNet 的辅助分类器在微调时容易干扰主损失,我一般把 aux_logits 关掉再训。
提示:三个骨干网不要同时训,先跑 ResNet50 拿到 baseline,再换 DenseNet121 对比,最后用 GoogleNet 做融合。单卡 8G 显存足够跑 ResNet50 和 GoogleNet,DenseNet121 建议 12G 以上。
2.2 用 torchvision 加载预训练模型并替换分类头
先装环境,PyTorch 2.x 加 torchvision,CUDA 版本按自己显卡选。数据目录按 ImageFolder 格式组织:train/abcd 四个子文件夹,val 同样。下面是最小跑通代码,直接抄。
import torch import torch.nn as nn import torchvision.models as models from torchvision import transforms, datasets from torch.utils.data import DataLoader # 数据增强:三视图需要保留线条,不要用 ColorJitter 和 RandomRotation train_tf = transforms.Compose([ transforms.Resize((256, 256)), # 先放大再裁剪,保留标注 transforms.RandomCrop(224), # 随机裁剪模拟图纸偏移 transforms.RandomHorizontalFlip(), # 三视图左右翻转不改变类别语义 transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_tf = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train_ds = datasets.ImageFolder('data/train', transform=train_tf) val_ds = datasets.ImageFolder('data/val', transform=val_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4) def build_model(arch='resnet50', num_classes=4): if arch == 'resnet50': model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) model.fc = nn.Linear(model.fc.in_features, num_classes) elif arch == 'densenet121': model = models.densenet121(weights=models.DenseNet121_Weights.IMAGENET1K_V1) model.classifier = nn.Linear(model.classifier.in_features, num_classes) elif arch == 'inception_v3': model = models.inception_v3(weights=models.Inception_V3_Weights.IMAGENET1K_V1, aux_logits=False) model.fc = nn.Linear(model.fc.in_features, num_classes) return model model = build_model('resnet50').cuda()逻辑说明:Resize 到 256 再 RandomCrop 224,比直接 Resize 224 多保留 14% 的像素信息,对细线条友好。RandomHorizontalFlip 对三视图安全,因为左右翻转不改变“这是主视图还是俯视图”的类别定义。Normalize 用 ImageNet 统计量,因为预训练权重就是在这个分布上学的。
参数说明:batch_size 32 是 8G 显存的保守值,ResNet50 可以开到 64,DenseNet121 降到 16。num_workers 设 4 是经验值,Windows 下如果报错就改 0。weights 参数用新版枚举,老代码的 pretrained=True 在 torchvision 0.13 之后会警告。
2.3 冻结与解冻:自适应迁移学习的核心操作
自适应迁移学习的关键不是“冻不冻”,而是“什么时候解冻、解冻几层”。我的做法分三段:第一段只训分类头,冻结所有卷积层,学习率 1e-3,跑 10 个 epoch;第二段解冻 layer4(ResNet)或 denseblock4(DenseNet),学习率降到 1e-4,跑 20 个 epoch;第三段全部解冻,学习率 1e-5,跑 10 个 epoch。这样做的原因是:分类头随机初始化,一开始梯度大,如果直接全网络微调会把预训练权重冲垮。
def set_freeze(model, arch, stage): # stage 1: 只训分类头 # stage 2: 解冻最后一块 # stage 3: 全部解冻 if arch == 'resnet50': blocks = [model.layer1, model.layer2, model.layer3, model.layer4] elif arch == 'densenet121': blocks = [model.features.denseblock1, model.features.denseblock2, model.features.denseblock3, model.features.denseblock4] else: blocks = [model.Mixed_5b, model.Mixed_6a, model.Mixed_7a] for p in model.parameters(): p.requires_grad = False # 分类头始终可训 if arch == 'resnet50': for p in model.fc.parameters(): p.requires_grad = True elif arch == 'densenet121': for p in model.classifier.parameters(): p.requires_grad = True else: for p in model.fc.parameters(): p.requires_grad = True if stage >= 2: for p in blocks[-1].parameters(): p.requires_grad = True if stage >= 3: for blk in blocks: for p in blk.parameters(): p.requires_grad = True # 训练循环骨架 optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3, weight_decay=1e-4) criterion = nn.CrossEntropyLoss(label_smoothing=0.1)逻辑说明:filter(lambda p: p.requires_grad, ...) 保证优化器只更新解冻的参数。label_smoothing 0.1 对工业图纸有用,因为有些图纸介于 A 和 B 之间,硬标签会过拟合。AdamW 的 weight_decay 比 Adam 更稳,1e-4 是微调场景的常用值。
参数说明:stage1 学习率 1e-3,stage2 换 1e-4,stage3 换 1e-5。每个 stage 结束时保存验证集准确率最高的权重。如果 stage2 验证集准确率下降超过 2%,说明解冻太早,退回 stage1 多跑 5 个 epoch。
3. 三视图数据增强与类别不平衡的工程处理
3.1 针对机械图纸的增强策略
通用图像增强在机械图纸上有一半不能用。ColorJitter 会改变线条对比度,导致细线消失;RandomRotation 会把水平标注转成斜的,OCR 特征全乱;GaussianBlur 直接糊掉尺寸数字。能用的只有:随机裁剪、水平翻转、轻微透视变换、以及 Cutout。Cutout 在图纸上效果意外地好,因为遮挡一部分区域强迫网络看其他视图的线索。
from torchvision.transforms import RandomApply import torchvision.transforms as T train_tf_advanced = T.Compose([ T.Resize((256, 256)), T.RandomCrop(224), T.RandomHorizontalFlip(p=0.5), T.RandomApply([T.RandomPerspective(distortion_scale=0.1, p=0.3)], p=0.3), T.ToTensor(), T.RandomErasing(p=0.25, scale=(0.02, 0.1), ratio=(0.3, 3.3)), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])逻辑说明:RandomPerspective 的 distortion_scale 只给 0.1,模拟扫描时的轻微倾斜。RandomErasing 就是 Cutout 的 torchvision 实现,scale 0.02 到 0.1 是擦除小方块,不会盖住整个视图。p=0.25 表示 25% 的概率执行。
参数说明:如果验证集准确率波动大于 3%,把 RandomErasing 的 p 降到 0.1。RandomPerspective 的 p 不要超过 0.3,否则图纸变形太厉害。
3.2 类别不平衡的加权采样与损失函数
ABCD 四类图纸数量往往不均,A 类可能 500 张,D 类只有 80 张。直接训会让网络偏向多数类。两种做法:WeightedRandomSampler 过采样少数类,或者 Focal Loss 降权易分样本。我一般两个一起用,采样器保证每个 batch 里四类都有,Focal Loss 让网络关注难分的 D 类。
from torch.utils.data import WeightedRandomSampler import numpy as np targets = [s[1] for s in train_ds.samples] class_count = np.bincount(targets) class_weights = 1.0 / class_count sample_weights = [class_weights[t] for t in targets] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True) train_loader = DataLoader(train_ds, batch_size=32, sampler=sampler, num_workers=4) # Focal Loss class FocalLoss(nn.Module): def __init__(self, alpha=1, gamma=2): super().__init__() self.alpha = alpha self.gamma = gamma def forward(self, inputs, targets): ce = nn.functional.cross_entropy(inputs, targets, reduction='none') pt = torch.exp(-ce) return (self.alpha * (1 - pt) ** self.gamma * ce).mean()逻辑说明:class_weights 取倒数,样本越少权重越大。WeightedRandomSampler 的 replacement=True 允许重复采样,保证每个 epoch 看到的样本数跟原数据集一致。Focal Loss 的 gamma=2 是原论文推荐值,alpha=1 表示不额外调类别权重,因为采样器已经处理了。
参数说明:如果 D 类验证集召回率低于 60%,把 gamma 提到 3。如果训练 loss 震荡,把 alpha 降到 0.5。
4. 训练、验证与模型融合的完整命令流
4.1 分阶段训练脚本与日志监控
把前面的模块拼起来,写一个 train.py,用 argparse 传参。每个 stage 结束打印混淆矩阵,不要只看准确率。机械图纸的 A 类和 B 类容易混,因为都是主视图加剖视,区别在标注符号。混淆矩阵能看出到底哪两类在互相误判。
import argparse, torch, numpy as np from sklearn.metrics import confusion_matrix, classification_report def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0, 0, 0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() out = model(imgs) loss = criterion(out, labels) loss.backward() optimizer.step() total_loss += loss.item() * imgs.size(0) correct += (out.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total @torch.no_grad() def evaluate(model, loader, device): model.eval() preds, gts = [], [] for imgs, labels in loader: imgs = imgs.to(device) out = model(imgs) preds.extend(out.argmax(1).cpu().numpy()) gts.extend(labels.numpy()) return np.array(gts), np.array(preds) # 主流程 parser = argparse.ArgumentParser() parser.add_argument('--arch', default='resnet50') parser.add_argument('--stage', type=int, default=1) parser.add_argument('--epochs', type=int, default=10) parser.add_argument('--lr', type=float, default=1e-3) args = parser.parse_args() device = 'cuda' if torch.cuda.is_available() else 'cpu' model = build_model(args.arch).to(device) set_freeze(model, args.arch, args.stage) optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=args.lr, weight_decay=1e-4) criterion = FocalLoss(gamma=2) for epoch in range(args.epochs): tr_loss, tr_acc = train_one_epoch(model, train_loader, optimizer, criterion, device) gts, preds = evaluate(model, val_loader, device) val_acc = (gts == preds).mean() print(f'stage{args.stage} epoch{epoch} loss{tr_loss:.4f} train{tr_acc:.4f} val{val_acc:.4f}') if epoch % 5 == 0: print(confusion_matrix(gts, preds)) print(classification_report(gts, preds, target_names=['A','B','C','D']))逻辑说明:train_one_epoch 返回平均 loss 和训练准确率,evaluate 收集所有预测和标签算验证准确率。每 5 个 epoch 打印混淆矩阵和分类报告,方便定位问题类别。
参数说明:stage1 用 lr=1e-3 epochs=10,stage2 用 lr=1e-4 epochs=20,stage3 用 lr=1e-5 epochs=10。命令行依次跑:
python train.py --arch resnet50 --stage 1 --epochs 10 --lr 1e-3 python train.py --arch resnet50 --stage 2 --epochs 20 --lr 1e-4 python train.py --arch resnet50 --stage 3 --epochs 10 --lr 1e-54.2 三个骨干网的预测概率融合
单模型准确率到 88% 左右就上不去了,融合能拉到 92% 以上。做法很简单:三个模型分别对验证集输出 softmax 概率,然后加权平均。权重按各自验证集准确率分配,ResNet50 给 0.4,DenseNet121 给 0.35,GoogleNet 给 0.25。
def ensemble_predict(models, loader, device, weights): all_probs = [] for model in models: model.eval() probs = [] with torch.no_grad(): for imgs, _ in loader: imgs = imgs.to(device) out = torch.softmax(model(imgs), dim=1) probs.append(out.cpu()) all_probs.append(torch.cat(probs, dim=0)) # 加权平均 final = sum(w * p for w, p in zip(weights, all_probs)) return final.argmax(1) resnet = build_model('resnet50').cuda() densenet = build_model('densenet121').cuda() googlenet = build_model('inception_v3').cuda() resnet.load_state_dict(torch.load('resnet_stage3_best.pth')) densenet.load_state_dict(torch.load('densenet_stage3_best.pth')) googlenet.load_state_dict(torch.load('googlenet_stage3_best.pth')) preds = ensemble_predict([resnet, densenet, googlenet], val_loader, 'cuda', [0.4, 0.35, 0.25])逻辑说明:每个模型先算 softmax 概率,再按权重加权。权重不是拍脑袋,是拿验证集准确率归一化得到的。如果某个模型准确率明显低,权重降到 0.1 以下,或者直接踢掉。
参数说明:融合前确保三个模型用的是同一套验证集增强(val_tf),否则概率分布不可比。如果显存不够同时加载三个模型,可以分两次跑,把概率存成 npy 文件再融合。
5. 避坑与排查:三视图分类翻车实录
5.1 验证集准确率比训练集高 5 个点
现象:训练 loss 降到 0.2,训练准确率 95%,验证准确率 97%。原因:验证集用了 CenterCrop,训练集用 RandomCrop,验证集图像更“标准”,网络在验证集上反而好分。解决:把验证集的 Resize 和 Crop 改成跟训练集一致的 RandomCrop,但推理时用 CenterCrop。或者干脆把验证集也做轻微增强,取多次预测的平均。
5.2 DenseNet121 训到第 3 个 epoch 显存爆了
现象:batch_size 设 32,跑到第 3 个 epoch 报 CUDA out of memory。原因:DenseNet 的密集连接导致中间激活值累积,PyTorch 默认不释放。解决:batch_size 降到 16,加 torch.cuda.empty_cache(),或者用 gradient checkpointing。torchvision 的 DenseNet 不支持原生 checkpointing,得手动包。
5.3 GoogleNet 的 aux_logits 导致 loss 不下降
现象:Inception v3 训练 loss 在 1.2 附近震荡,验证准确率随机水平。原因:aux_logits=True 时,辅助分类器的 loss 跟主 loss 相加,但辅助分类器在微调时梯度方向跟主任务不一致。解决:build_model 里设 aux_logits=False,或者训练时只取主输出 out[0]。
5.4 混淆矩阵显示 A 类和 D 类互相误判
现象:A 类召回率 95%,D 类召回率 40%,D 类样本大量被分到 A。原因:D 类样本太少,WeightedRandomSampler 虽然过采样,但 Focal Loss 的 alpha=1 没有额外加权。解决:Focal Loss 的 alpha 按类别频率设成 tensor,D 类给 3.0,A 类给 0.5。或者把 D 类的增强强度加大,RandomErasing 的 p 提到 0.4。
5.5 推理时单张图预测结果跟验证集不一致
现象:验证集准确率 92%,但拿单张图预测,结果跟验证集里同一张图的预测不同。原因:验证集用了 DataLoader 的 shuffle=False,但 batch 内的 BatchNorm 统计量受同 batch 其他图影响。解决:推理时设 model.eval(),并且 batch_size=1 逐张预测。如果必须批量,确保 batch 内图像来自同一分布。
6. 用 Grad-CAM 验证模型到底在看三视图的哪个区域
训完模型不算完,得知道它是不是真的在看视图,而不是在背背景。Grad-CAM 能把最后一层卷积的梯度加权回原图,生成热力图。我一般对每个类别抽 5 张验证集图,看热力图是否落在视图轮廓和标注区域。如果热力图集中在图纸边框或标题栏,说明模型学到了捷径,得重新设计增强或裁剪。
from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image import cv2, numpy as np model = build_model('resnet50').cuda() model.load_state_dict(torch.load('resnet_stage3_best.pth')) model.eval() target_layers = [model.layer4[-1]] # ResNet50 最后一层 bottleneck cam = GradCAM(model=model, target_layers=target_layers) img = cv2.imread('data/val/A/sample_001.png') img = cv2.resize(img, (224, 224)) rgb = np.float32(img) / 255.0 input_tensor = val_tf(image=img)['image'].unsqueeze(0).cuda() # 需配合 albumentations grayscale_cam = cam(input_tensor=input_tensor, targets=None) visualization = show_cam_on_image(rgb, grayscale_cam[0], use_rgb=True) cv2.imwrite('cam_A_001.jpg', visualization)逻辑说明:target_layers 选 layer4 的最后一个 bottleneck,这是 ResNet50 语义最强的层。targets=None 表示用预测类别作为目标。show_cam_on_image 把热力图叠加到原图。
参数说明:如果热力图太散,把 target_layers 换成 layer3[-1],感受野更小,定位更准。DenseNet121 用 model.features.denseblock4,GoogleNet 用 model.Mixed_7c。
我自己的习惯是:每次换骨干网或改增强策略,先跑 10 张 Grad-CAM 图扫一眼。如果热力图集中在视图区域,继续训;如果跑到标题栏或空白处,立刻停,回去查数据增强和裁剪逻辑。这个习惯帮我省了至少两周的无效训练。希望帮到你。
本文还有配套的精品资源,点击获取