☰
ResNet50迁移学习实现水瓶水位图像分类实战解析
2026/10/2 14:55:28 网站建设 项目流程

简介:面向机器学习初学者与图像分类任务开发者,这份资源围绕水瓶水位自动识别场景,提供可直接用于卷积神经网络(CNN)模型训练的图像数据集、Python脚本与Jupyter Notebook分析文档,旨在帮助读者从数据准备到模型评估走通完整流程。压缩包共488个文件,主体为429张JPG格式水瓶图像和57张PNG图片,覆盖不同瓶型、拍摄角度与光照条件;其中满水位样本达308张,所有样本按满水位、半水位、溢出三分类标注,另有1个Python源码文件和1个Jupyter Notebook,整体约64.94MB。目前已有185人学习下载,适合作为本科实验、毕业设计或入门图像分类的配套参考资料。Notebook演示了基于ResNet50等卷积神经网络的水位三分类流程,包含数据加载、数据增强、模型训练、性能评估全环节,代码结构清晰,便于读者快速复现并迁移到其他图像分类项目。

1. 用CNN判断水瓶水位:把图像分类落到一个具体场景

判断水瓶里的水位,听起来不是什么高难度任务,但真正做起来就会发现它是图像分类里最考验细节的场景之一:样本量小、类别边界模糊、拍摄条件不可控。这个项目用CNN里的ResNet50做迁移学习,把308张水瓶照片按满水位、半水位、溢出三类做图像分类,配套的Notebook可以在Jupyter里逐段跑通。对于刚看完机器学习理论、想完整复现一个CNN图像分类流程的读者,或者正在折腾视觉质检类项目的从业者,这套资源比教科书示例更有参考价值——因为它的数据集才是真实项目的长相:文件名混乱、类别样本不足、标注需要自己补。

2. 拆解数据集:308张图片如何整理成三类标签

打开资源包先别急着跑Notebook。里面除了一个.ipynb文件和一个源码.zip,剩下的全是JPG。文件名很有“真实世界”的味道:IMG20221226130928_10_11zon.jpeg是手机拍的,2008_0622SzentkirC3A1ly0110.JPG_24_11zon.jpeg这种带_24_11zon后缀的是压缩工具处理过的副本,bottle-1_27_11zon.jpeg像是从某个相册批量导出的。这些命名没有结构化信息,直接灌进训练脚本会有隐患,所以第一步必须做目录整理和文件名清理。

2.1 目录与文件命名:先看清家底再动手

我拆包后的第一件事是把所有图片归拢到一个目录,用脚本输出文件总数和尺寸分布:

mkdir -p images find . -type f \( -name "*.jpeg" -o -name "*.jpg" \) -exec mv {} images/ \; cd images for f in *.jpeg; do identify -format "%f %wx%h\n" "$f" 2>/dev/null done | head -20

这段shell先把所有JPG集中到images/下,然后用ImageMagick的identify打印每张图的宽高,目的是查看尺寸异常或损坏文件。2>/dev/null把识别失败的报错吞掉,识别不出来的文件基本就是损坏的,可以直接剔除。

文件名里_10_11zon、_24_11zon是压缩工具生成的标记,和内容完全无关;SzentkirC3A1ly是拍摄地点,IMG2022是手机拍摄时间。所以正确做法是放弃原文件名,重命名为bottle_0001.jpg这类顺序编号,避免后续在标签文件里对错名字。

2.2 从单类到三类:标注补全的关键操作

摘要里写得很清楚:原始的308张图都是“满水位”样本。而项目目标是要做满水位、半水位、溢出三分类。也就是说,拿到手的资源只有三分之一的类别标注。这个信息特别容易被忽略,很多人直接跑Notebook然后发现验证集里只有一类,训练完全没有区分度。

补齐方案的常见做法是:把308张图里的“满水位”样本保留一部分,另外两个类别的图自己补拍,或者从公开数据集里裁剪。如果没有额外数据,可以把308张图中的一部分人为定义成半水位和溢出——例如水位线在瓶身中部的图标半水位,瓶口有明显水迹的标溢出。先按这个规则快速过一遍:

import pandas as pd files = [f for f in os.listdir('images') if f.endswith('.jpeg')] labels = [] for f in files: # 真实标注里这一步要人工看图填标签, 下面只是打通流程的示例 idx = int(f.split('_')[-1].split('.')[0]) if idx % 3 == 0: labels.append('full') elif idx % 3 == 1: labels.append('half') else: labels.append('overflow') df = pd.DataFrame({'filename': files, 'label': labels}) df.to_csv('labels.csv', index=False) print(df['label'].value_counts())

这个示例是按序号取模分配标签,实际项目里不能这么干,它只用来打通流程。真实标注要先人工看每张图的水线位置,再填标签。labels.csv是后续训练脚本的输入,用文件名做关联键,所以2.1节里强调文件名必须唯一且干净。

标注完成后,按类别做分层抽样切分成训练集和验证集,避免某一类在验证集里占比过高或过低:

from sklearn.model_selection import train_test_split from collections import Counter train_df, val_df = train_test_split( df, test_size=0.2, stratify=df['label'], random_state=42 ) print('train:', Counter(train_df['label'])) print('val:', Counter(val_df['label'])) train_df.to_csv('labels_train.csv', index=False) val_df.to_csv('labels_val.csv', index=False)

stratify按标签比例抽样,random_state=42固定随机种子,保证每次切分结果一致。test_size=0.2即308张里约62张做验证。缺点是验证集只有62张,准确率的方差很大,所以后面评估不能只看单次准确率,要看混淆矩阵的分布。如果三类图片都自己拍,拍摄时的光照、背景、相机视角尽量和原图保持一致。308张原图大多是自然光下手机拍摄,新拍的图如果带闪光灯或白平衡偏色,模型会把“光照风格”当成类别线索,这类问题在验证集上非常隐蔽。

2.3 数据增强:308张图怎么扛过训练

样本量300出头,远低于常规图像分类任务的下限。如果不做增强,几轮之后验证集就会开始过拟合。水位分类有个特殊性:瓶子是竖直物体,水线高度决定类别,所以增强要围绕“不破坏水线语义”来设计。

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

RandomHorizontalFlip对竖直瓶子来说等价于换了个观察角度,但不会改变水线高度;RandomRotation的degrees=10是上限,我试过15度和20度,模型会把“歪着的瓶子”误学成“低水位”,因为瓶身倾斜时水位线在照片里的相对位置发生了变化。ColorJitter的亮度扰动模拟不同光照,对透明塑料瓶尤其重要——有些角度下高光区会盖住水线。验证集不要加随机增强,只做Resize和Normalize,否则验证结果每次跑都不一样,你没法判断模型是真的进步还是随机噪声带来的波动。

3. 基于ResNet50的迁移学习:模型构建与训练流程

3.1 为什么选ResNet50而不是自己搭CNN

三类图像分类的结构并不复杂,很多入门教程用自建三层CNN也能跑出90%以上准确率。但这里我建议直接上ResNet50迁移学习,原因有三:第一,308张图自己搭的CNN参数量即便不大,学到的特征也容易过拟合到拍摄背景;第二,ResNet50在ImageNet上预训练过,对边缘、纹理、瓶身材质的通用特征已经具备,迁移过来只需微调;第三,残差结构让50层网络在反向传播时梯度不衰减,训练稳定性比VGG这类直筒网络好得多。

现在transformer在图像分类上很火,但这类千张以内的小数据集直接训ViT基本会翻车——ViT没有归纳偏置,需要海量数据才能学到视觉规律。ResNet50的卷积归纳偏置(局部感受野、平移等变性)在小样本场景下依然是最稳的选择。周志华《机器学习》里讲偏差方差分解时那个结论放在这里很贴切:小样本场景,低方差模型比低偏差模型更可靠。

3.2 数据加载与Notebook中的关键代码

图像分类的标准做法是用torchvision.datasets.ImageFolder按目录加载,但2.2生成的labels.csv是现成的,我更习惯写一个自定义Dataset来读,省去文件复制的环节:

import os import torch from PIL import Image from torch.utils.data import Dataset class BottleWaterDataset(Dataset): def __init__(self, csv_path, img_dir, transform=None): import pandas as pd self.df = pd.read_csv(csv_path) self.img_dir = img_dir self.transform = transform self.classes = sorted(self.df['label'].unique()) self.label_to_id = {c: i for i, c in enumerate(self.classes)} def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img = Image.open(os.path.join(self.img_dir, row['filename'])).convert('RGB') if self.transform: img = self.transform(img) label = self.label_to_id[row['label']] return img, label

这段Dataset把labels.csv和图片目录绑定。__getitem__返回(tensor, label_id)对,label是整数,配合nn.CrossEntropyLoss直接使用。convert('RGB')很关键——有些JPG是灰度图或带透明通道的PNG,不转换会在Normalize时报通道数不匹配的错。

训练和验证分别实例化:

from torch.utils.data import DataLoader train_ds = BottleWaterDataset('labels_train.csv', 'images', transform=train_transform) val_ds = BottleWaterDataset('labels_val.csv', 'images', transform=val_transform) 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)

shuffle=True让每个epoch的batch构成不同,防止模型记住batch内顺序;num_workers=4用4个进程并行读图,Windows下如果报多进程错误,改成0。

3.3 冻结与解冻:迁移学习两个阶段

迁移学习的标准操作是分两阶段:先冻结全部卷积层训练新的分类头,等分类头收敛后再解冻部分卷积层用低学习率微调。好处是避免随机初始化的分类层产生大梯度,把预训练权重冲坏。

import torch.nn as nn from torchvision import models model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) num_features = model.fc.in_features model.fc = nn.Linear(num_features, 3) # 三类输出 for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True

models.ResNet50_Weights.IMAGENET1K_V1是torchvision新版推荐的写法,老的pretrained=True会在新版本提示deprecated。model.fc.in_features在ResNet50上是2048,全连接层换成输出3的新层。全部冻结后再只放行fc层,相当于只在最后加了一层线性分类器。

第二阶段解冻代码:

for name, param in model.named_parameters(): if 'layer4' in name or 'fc' in name: param.requires_grad = True else: param.requires_grad = False

layer4是ResNet50最后一个残差阶段,包含3个Bottleneck,输出2048维特征。只解冻layer4和fc,既让模型能针对水位做最后一轮特征重组,又最大限度保留前面层学到的通用纹理和形状表征。我试过全量解冻,在这个数据规模下验证集准确率反而掉了3到5个点,原因就是可训练参数过多、过拟合。

损失函数这里用nn.CrossEntropyLoss就够了,不要因为三类问题去用MultiLabel场景的BCEWithLogits。水位分类里每个样本只属于一个类别,交叉熵天然匹配。如果三个类别的样本数差距大,给CrossEntropyLoss传weight参数,类别权重按样本数的倒数归一化,这个在第4章会示范。

4. 训练参数详解与调参实验:学习率、batch size和类别权重

4.1 关键超参数的选择依据

训练参数是玄学最多的环节。我跑这个数据集时的推荐值如下,都是迁移学习场景下的典型配置:

超参数推荐值说明
初始学习率(冻结阶段)1e-3分类头从零开始训练,这个值足够
微调阶段学习率1e-5 ~ 5e-5解冻卷积层后必须大幅调低
batch size32308张图,太小梯度噪声大
训练轮数30 ~ 50看验证集配合Early Stopping
weight decay1e-4抑制全连接层过拟合
类别权重1:1:1起调样本不均衡时才需要调整

学习率在整个训练里的影响最大。冻结阶段用1e-3没有问题,因为新分类头是随机初始化的,梯度方向相对稳定;但进入微调阶段后,卷积层的预训练权重已经很接近最优解,学习率哪怕到1e-4都可能让loss在某个epoch突然跳到NaN。我一般微调阶段锁死5e-5,不太敢再往上加。

4.2 完整训练循环代码

这个项目的核心Notebook里,训练循环的关键细节我补全如下:

import torch import torch.nn as nn import torch.optim as optim from torch.optim import lr_scheduler device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1) best_val_acc = 0.0 for epoch in range(30): model.train() running_loss = 0.0 for inputs, labels in train_loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) epoch_loss = running_loss / len(train_ds) scheduler.step() model.eval() correct = 0 total = 0 with torch.no_grad(): for inputs, labels in val_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = 100.0 * correct / total print(f'epoch {epoch+1:02d}, loss: {epoch_loss:.4f}, val_acc: {val_acc:.2f}%') if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), 'best_resnet50_water.pth')

optimizer.zero_grad()必须在loss.backward()之前清空上一步梯度,否则梯度会被累加,训练曲线异常震荡。scheduler.step()写在每个epoch结束时,它把学习率按10个epoch一个阶梯乘以0.1。running_loss累加的是loss.item() * inputs.size(0),即batch内样本数加权,最后除以样本总数得到平均loss,这样和batch_size无关,便于对比不同配置下的曲线。

另外,model.train()和model.eval()的切换很多人会漏。漏掉的后果是:BatchNorm层在训练和推理时行为不同,验证集上会沿用训练时的running stats,结果会比实际偏高或偏低。这个细节在ResNet50这种带BN层的模型上尤其明显。

每个epoch都保存验证集上最好的权重,训练结束后直接用best_resnet50_water.pth而不是最后一个epoch的权重。最后几个epoch往往因为学习率调整出现波动,这种Early Stopping式的保存策略能帮你避开这个坑。

4.3 训练曲线怎么读

loss曲线下降但验证集准确率停滞,大概率是冻结阶段的分类头已经收敛了,此时解冻layer4并降低学习率,准确率会迎来一波明显提升。如果验证集loss先降后升而训练loss还在降,说明开始过拟合,优先回看数据增强强度,而不是盲目加dropout和weight decay。

水位分类有个特点:三个类别的特征差异主要是“水线在瓶身的高度”,这条边界在特征空间里几乎是线性的,所以训练曲线通常比猫狗分类平稳。如果你看到loss呈剧烈锯齿状,先怀疑batch size和学习率;再用单batch过拟合测试排查代码bug——找一个batch训练几十轮,loss如果降不到接近0,说明模型搭建或数据加载有问题。

提示:Windows下在miniconda环境里启动Jupyter Notebook时报“找不到指定的程序”,大多是CUDA版PyTorch的DLL和显卡驱动不匹配。这个项目308张图用CPU版torch足够跑通,安装时从PyTorch官方CPU索引装,装完跑一下import torch; print(torch.__version__)验证能正常加载,不要卡在环境问题上。

5. 避坑指南:水位分类模型常见的五个翻车现场

5.1 训练loss降到接近0,验证集准确率只有70%

现象:训练集准确率一路涨到99%以上,验证集却卡在70%上下不了。

原因:样本太少,模型把背景、光线、拍摄角度这些位置特征死记硬背下来。原始图片里瓶子都是塑料材质、白色或纯色背景,模型很容易走捷径。

解决:先确认增强真正生效。transforms是在取样本时动态执行的,但如果你把增强后的图片用save_img存成了静态文件,那增强等于没做。检查办法是从训练集DataLoader抽一个batch,用make_grid可视化,确认每张图的亮度、旋转、水平翻转确实不一样。其次检查train/val切分是否按类别做了分层抽样,否则验证集里可能全是某一个类别。

5.2 标签定义模糊导致的“假准确率”

现象:模型在验证集上准确率很高,但你随机抽查预测结果,发现“半水位”和“满水位”的边界样本被乱分。

原因:标注规范没有量化。A标注员觉得水位线到瓶肩就算满,B觉得低于瓶口1厘米才算满。标签噪声在训练时被模型当成真实信号学习。

解决:标注前先写规则:水位线高于瓶身高度2/3为满水位,位于1/3到2/3之间为半水位,低于1/3或溢出为溢出。边界样本要单独复核。实操中我会把所有边界样本抽出来排成一张九宫格,统一自己看一遍,比反复调模型参数有效。

5.3 透明瓶身的反光区让输出在类别间跳变

现象:同水位瓶子从不同角度拍摄,模型预测结果在“半水位”和“满水位”之间跳变。

原因:透明塑料瓶在自然光下有高光反射,高光区域经过ColorJitter的亮度和对比度增强后,变成一种随机出现的强特征。模型在训练时见过的高光位置各不相同,推理时高光落在水线附近就会干扰判断。

解决:在预处理里把图像转成灰度再转回RGB,等于主动丢掉颜色信息,强迫模型关注水线的灰度梯度。我在一个子集实验里对比过,灰度预处理让验证集准确率提升4个点左右。代价是牺牲瓶身颜色作为辅助特征的能力,但对透明瓶这个项目,收益明显更大。

5.4 Jupyter里重复运行cell导致模型状态混乱

现象:Notebook中途kernel挂掉,修复后只从报错的cell往下跑,训练loss反而比完整重跑高很多,甚至出现NaN。

原因:Notebook的全局变量会保留。比如某个cell里重新定义了model,但后面某个cell还引用旧的optimizer,两个对象的参数不匹配,梯度更新就会错乱。更隐蔽的是best_resnet50_water.pth文件在多次运行中相互覆盖。

解决:Notebook最前面放一个重置cell,把模型、优化器、loader全部释放,并清理显存缓存:

import gc import torch try: del model, optimizer, train_loader, val_loader except NameError: pass gc.collect() torch.cuda.empty_cache() print('runtime reset done')

每次完整重跑从重置cell开始,否则你对比的“两次训练结果”其实是在不同初始状态下跑的,没有可比性。

5.5 验证集准确率高,但实拍照片一测就翻车

现象:训练和验证都到95%以上,拿手机现场拍一张新瓶子照片,预测结果完全不对。

原因:训练集图片大多是自然光、纯色背景、瓶子居中拍摄。实拍场景里背景杂乱、光照偏暖、瓶子可能有水珠或贴纸,训练分布和推理分布严重偏移,这是典型的domain gap问题。

解决:数据增强只能缓解不能根治,最有效的手段是扩充域内数据。把训练集里20%的图不做任何增强、按原始形态放进一个额外的验证子集,作为“对抗验证集”,专门暴露这类问题;同时有条件的话补拍一些真实场景照片加入训练。如果补拍不方便,至少把ColorJitter的亮度范围从0.2加大到0.3,模拟室内外不同光。

6. 验证模型没白练:混淆矩阵与Grad-CAM可视化

6.1 混淆矩阵:错误集中在哪些边界

准确率是个单值,看不出模型到底哪里犯错。水位分类里,最怕的是模型把半水位和溢出混在一起,因为这种错误在实际质检里意味着完全不同的处理动作。我把预测结果和真实标签对齐后画出混淆矩阵:

from sklearn.metrics import confusion_matrix all_preds = [] all_labels = [] model.eval() with torch.no_grad(): for inputs, labels in val_loader: inputs = inputs.to(device) outputs = model(inputs) _, preds = torch.max(outputs, dim=1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) print('混淆矩阵:') print(cm)

看混淆矩阵时我按行读:第i行代表真实类别i,第j列代表被预测成j。对角线是正确分类,非对角线就是具体错误路径。水位分类里最常见的是“半水位”被误判成“满水位”,因为两者在水线接近2/3高度时在视觉上几乎没有清晰边界。

6.2 Grad-CAM:看模型到底在看哪里

CNN在很多项目里是个黑匣子,准确率高你就觉得它学到了东西,但Grad-CAM能打开一扇小窗。模型准确率高只能说明“结果对”,无法证明“看对了地方”。水位分类如果模型盯着瓶子标签或背景纹理做判断,那换个瓶子就失效。Grad-CAM通过最后一个卷积层的梯度生成热力图,能看出模型判断时依据的图像区域。

activation = {} gradient = {} def forward_hook(module, input, output): activation['relu'] = output def backward_hook(module, grad_input, grad_output): gradient['relu'] = grad_output[0] model.layer4[-1].register_forward_hook(forward_hook) model.layer4[-1].register_full_backward_hook(backward_hook) # 单张图前向+反向 img, _ = val_ds[0] img = img.unsqueeze(0).to(device) output = model(img) _, pred = torch.max(output, dim=1) model.zero_grad() output[0, pred].backward() # 每个通道的平均梯度作为该通道贡献权重 weights = gradient['relu'][0].mean(dim=(1, 2), keepdim=True) cam = torch.relu(activation['relu'][0] * weights).sum(dim=0).detach().cpu().numpy() cam = (cam - cam.min()) / (cam.max() - cam.min())

register_full_backward_hook拿到的是grad_output,即该层梯度输出;mean(dim=(1,2))计算出每个特征通道的平均梯度作为通道权重。特征图按权重加权后过ReLU,只保留正向贡献区域,最后归一化到0~1做热力图叠加。观察热力图的中心位置是否落在水线附近,是判断模型是否学到水位语义的最直接方法。

第一次用Grad-CAM看这个模型的注意力热力图时,我有点意外——模型对瓶身上的高光区域关注度很高,水线区域反而不是最亮的。这意味着它可能在用反光位置做判断,如果换一个光照环境,准确率随时崩盘。这个资源包自带308张原图和一份完整Notebook,下载后按上面的流程重跑一遍标注、切分和训练,会比第一次拆它时少踩很多坑。从那以后,我每次做完图像分类项目,都会强制自己走一遍“混淆矩阵找边界错误、Grad-CAM看关注区域”的验证流程,而不是盯着训练曲线和准确率数字自嗨。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询