☰
基于CNN的水位图像分类实战:数据集制作与模型训练
2026/10/10 13:43:43 网站建设 项目流程

简介:基于水位的机器学习水瓶图像分类项目,面向机器学习初学者与图像分类开发者,旨在训练模型识别满水位、半水位或溢出三种状态的水瓶图像。压缩包共488个文件,包含429张JPEG与57张PNG图像,以及1个Python脚本和1个Jupyter Notebook,整体大小约64.94MB,数据集中提供了308张满水位水瓶照片,覆盖多种瓶型、尺寸与拍摄角度,便于开展数据预处理和模型训练。目前已吸引185人学习浏览,资源内Notebook基于CNN与ResNet50架构,可直接运行并跟踪完整流程,适合用于课程设计、毕业设计或CNN入门实战。读者可借助Notebook与Python脚本快速理解图像分类建模思路,并复用数据集进行迁移学习或精度优化实验。

1. 水位图像分类这件事,为什么非得上CNN

水位分类这个需求,第一反应是颜色阈值、边缘检测、找水平线这些传统视觉手段。但真正拍过一版数据就知道:瓶身反光、桌面倒影、日光灯高光、透明瓶和磨砂瓶质感不一样,传统方案在实验室里跑得好好的,换个环境就翻车。这个项目直接用CNN端到端学习"水位长什么样"——输入一张水瓶照片,输出空瓶、低水位、半瓶、高水位、满瓶五类。配套的Jupyter Notebook把数据加载、模型训练、效果评估串成一条线,Windows笔记本上CPU也能完整跑通。它适合三类人:做机器学习课设但没想好题目的学生、想入门图像分类又不想啃大项目的开发者、以及要做水位监测视觉预研但还没定方案的工程师。从拍照到出分类结果,通常一个周末能走完。

2. 自建水位数据集:类别怎么定、照片怎么拍、划分脚本照抄

2.1 水位等级怎么分:五类边界和拍摄规范

类别定义是整个项目的地基。常见做法是分五类:0空瓶、1低水位(约0-25%)、2半瓶(25-50%)、3高水位(50-75%)、4满瓶(75-100%)。

为什么不直接回归一个连续水位高度值?因为连续回归需要精确标注每个样本的液面高度,手工标起来非常痛苦,而且瓶身形状不规则,同样高度在不同瓶子里对应的水量并不一样。分类任务就轻松很多:人眼判断"这瓶水是一半还是八分满"几乎没有压力,标注速度快得多,模型学起来也稳定。

边界样本是重点。25%、50%、75%这几个临界点最容易让模型犯迷糊,因为相邻类别的外观差异本来就小。拍摄时在瓶身贴标签纸标出刻度,或者用带刻度的量杯先量好水量再倒进瓶子里拍,能显著减少标注含糊的情况。

拍摄规范直接决定训练效果的上限,以下几点是反复试出来的:

  • 相机或手机固定高度,正对瓶子,镜头略低于瓶口,避免俯仰角变化引入透视差异
  • 背景用纯色纸板或干净墙面,不要有文字、图案和别人走动
  • 避免强光直射,水面高光会盖住水面与空气的边界,导致"水面消失"
  • 每类至少拍100张,五个类别合计500张起步;想在验证集上效果稳一点,每类拍到150张以上

透明瓶和不透明瓶的处理略有不同。透明塑料瓶里水面和瓶身颜色非常接近,模型能学到的关键线索是水面与空气交界处的弯月面纹理;不透明瓶则简单得多,水位线就是一块明显的颜色分界。如果实际场景两种瓶子都有,数据集里最好都覆盖,但类别定义保持一致。

提示:拍摄时每张照片只放一个瓶子,瓶子尽量占据画面中央60%以上。不要把空瓶和满瓶放同框,那会让模型学到"位置关系"而不是"水位特征"。

2.2 数据增强做哪些:别让模型记住背景

小数据集的第一个敌人是过拟合,第二个敌人是"记住背景"。如果训练照片里瓶子永远在同一位置、同一背景,CNN根本不需要学水位,直接学"画面中央有个瓶子"就能在验证集上拿高分。数据增强的目的就是逼模型去学水面本身。

我一般用的增强组合如下:

from torchvision import transforms # 训练集增强:翻转 + 亮度/对比度扰动 + 随机裁边 + 轻微模糊 train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomHorizontalFlip(p=0.5), # 瓶子左右对称,翻转不破坏语义 transforms.ColorJitter(brightness=0.3, contrast=0.3), # 模拟不同光照环境 transforms.RandomResizedCrop((224, 224), scale=(0.8, 1.0)), # 模拟瓶子在画面中的位置变化 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对瓶子图像完全安全,因为瓶子水平翻转后水位语义不变。ColorJitter的幅度不要调太大,亮度过猛会把水面直接洗成白色块,反而丢特征。RandomResizedCrop的scale设为0.8到1.0,让瓶子在画面里的占比有轻微变化即可,裁剪太狠会把水位线截掉。

几个增强操作的参数怎么理解?brightness=0.3表示亮度在原始值的70%到130%之间随机变化,contrast同理。scale=(0.8, 1.0)表示裁剪区域占原图面积的80%到100%。如果你发现验证集准确率高但换一个真实环境就崩,优先加大brightness和RandomResizedCrop的扰动幅度。

2.3 训练集/验证集划分脚本:按瓶子分组才是关键

数据划分看起来是最没技术含量的一步,但这里有个大坑:不能按"图片"随机划分,要按"瓶子"划分。

先看错误示范,很多初学者是这么干的:

import os import random from sklearn.model_selection import train_test_split root = "data_raw" # 假设每类图片已经放在 data_raw/0_empty/ 这样的目录里 paths, labels = [], [] for cls_id, cls_name in enumerate(sorted(os.listdir(root))): cls_dir = os.path.join(root, cls_name) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): if fname.lower().endswith((".jpg", ".jpeg", ".png")): paths.append(os.path.join(cls_dir, fname)) labels.append(cls_id) # 按图片随机划分:同一个瓶子的不同照片可能同时进训练集和验证集 train_paths, val_paths, train_labels, val_labels = train_test_split( paths, labels, test_size=0.2, random_state=42, stratify=labels )

这段代码本身没语法错误,但这个划分方式有数据泄漏风险:同一个瓶子拍了10张照片,随机打散后可能6张进训练集、4张进验证集。模型只需要记住这个瓶子的纹理贴在哪个类别上,验证集就能拿高分,但它对新瓶子毫无泛化能力。这就是典型的"验证集骗人"。

正确做法是按瓶子分组。拍摄时就把每个瓶子的照片放进独立子目录,文件名或目录前缀带上瓶子编号,然后按瓶子划分:

import os import shutil from sklearn.model_selection import train_test_split # 目录结构:data_by_bottle/bottle_001/0_empty/xxx.jpg bottle_root = "data_by_bottle" out_train = "data/train" out_val = "data/val" bottles = [d for d in os.listdir(bottle_root) if os.path.isdir(os.path.join(bottle_root, d))] # 按瓶子编号划分,同一个瓶子的所有照片只能出现在同一侧 train_bottles, val_bottles = train_test_split( bottles, test_size=0.2, random_state=42 ) for split_name, bottle_list in [("train", train_bottles), ("val", val_bottles)]: for bottle in bottle_list: bottle_path = os.path.join(bottle_root, bottle) for cls_name in os.listdir(bottle_path): src_dir = os.path.join(bottle_path, cls_name) if not os.path.isdir(src_dir): continue dest_dir = os.path.join(out_train if split_name == "train" else out_val, cls_name) os.makedirs(dest_dir, exist_ok=True) for fname in os.listdir(src_dir): src = os.path.join(src_dir, fname) dest = os.path.join(dest_dir, f"{bottle}_{fname}") shutil.copy(src, dest)

逻辑说明:外层循环先按瓶子切分,内层再把每个瓶子的图片按类别复制到目标目录。复制出来的文件名加了瓶子编号前缀,既防止不同瓶子同名文件互相覆盖,也方便以后排查泄漏。random_state固定为42保证每次运行划分结果一致,复现实验结果时不会被随机性干扰。stratify参数不在按瓶子划分这一步使用,因为类别分布不均衡的问题可以靠后面的类别权重处理,不必在划分时强行均衡。

3. CNN模型搭建与训练参数:小网络跑通五分类

3.1 为什么用小型CNN而不是直接上预训练模型

这个项目里我坚持从零搭一个小CNN,而不是用ResNet50加载ImageNet预训练权重。原因有三层:

第一,任务本身不复杂。五类水位分类,图像内容简单,一个三到四层的卷积网络已经具备足够容量。用ResNet50属于杀鸡用牛刀,训练慢,参数多,在小数据集上反而更容易过拟合。

第二,预训练权重依赖外网下载。课设现场、内网环境经常下载失败,而一个随机初始化的7万参数小网络,CPU上十几分钟就能训完,完全不受网络限制。

第三,课设和入门场景经常要回答"模型结构为什么这样设计",小型CNN每一层在干什么都看得清清楚楚,方便画结构图、写答辩PPT。而ResNet50里的残差连接、瓶颈结构,对刚接触CNN的人来说更像黑匣子。

但如果是真实工程场景,我反而建议用ResNet18或MobileNetV3做迁移学习。真实产线的拍摄环境更复杂,样本量更容易做到每类几千张,预训练模型能省下大量调参时间。选型逻辑很简单:样本量小、任务语义简单、需要可控性强,就选小CNN;样本量大、任务复杂、上线要求高,就迁移学习。这个项目对应前者。

3.2 模型定义与数据加载的最小代码

模型结构选三层卷积加一个分类头。输入224x224的RGB图,经过三次"卷积+ReLU+池化"后,特征图变成28x28,通道数加到128。最后用自适应平均池化把特征压成一维向量,接Dropout和全连接层输出5个类别分数。

import torch.nn as nn class WaterLevelCNN(nn.Module): def __init__(self, num_classes=5): super().__init__() self.features = nn.Sequential( # 第一层:RGB输入,提取边缘和颜色块,输出32张特征图 nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 第二层:提取纹理组合,输出64张特征图 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 第三层:提取更高层语义,输出128张特征图 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), # 不管输入尺寸多大,都池化成1x1 nn.Flatten(), # 128 维向量 nn.Dropout(0.3), # 随机丢弃30%神经元,防过拟合 nn.Linear(128, num_classes), # 输出5类分数 ) def forward(self, x): return self.classifier(self.features(x))

参数演进路径:224x224输入经过第一次池化变成112x112,第二次变成56x56,第三次变成28x28。通道数从3到32到64到128,这是CNN的标准做法——空间分辨率逐步降低,通道数逐步加深。AdaptiveAvgPool2d是个容易被忽略的好东西,它让模型不再依赖固定输入尺寸,推理时传任意尺寸进去都能得到固定长度的特征。Dropout只加在全连接层前面,不加在卷积层里,因为卷积层本身靠权值共享抗过拟合,加Dropout反而会拖慢收敛。

数据加载直接用torchvision的ImageFolder,它要求目录结构必须是"类别文件夹/图片文件":

from torch.utils.data import DataLoader from torchvision import datasets # data/train 下已经有 0_empty/ 1_low/ 2_half/ 3_high/ 4_full/ 五个子目录 train_ds = datasets.ImageFolder("data/train", transform=train_transform) val_ds = datasets.ImageFolder("data/val", transform=val_transform) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=2) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=2) # 打印类别到索引的映射,确认目录名没有拼错 print(train_ds.class_to_idx)

ImageFolder会把子目录名按字母序映射成类别索引,比如0_empty对应0、4_full对应4。这里有个小坑:目录名千万不能带空格和中文,Windows下中文路径配合PyTorch的DataLoader偶尔会出编码问题,直接用数字前缀排序也符合人类直觉。shuffle只在训练集打开,验证集不需要,因为验证阶段要对齐每个batch的预测顺序,方便后面画混淆矩阵。

3.3 训练参数:学习率、批次大小、早停怎么设

训练配置直接影响能否收敛。这个项目我用过两组都有效的配置,给出参考:

参数配置A配置B说明
优化器Adam(lr=0.001)SGD(lr=0.01, momentum=0.9)Adam自适应学习率,SGD需要配合衰减
损失函数CrossEntropyLossCrossEntropyLoss多分类标配,内部已包含Softmax
batch_size3216CPU训练时16更快,GPU无差别
epoch3040配合早停,不是固定跑完
学习率调度ReduceLROnPlateauStepLR(step=15, gamma=0.1)验证loss停滞时降学习率

两个经验:小数据集上Adam几乎总是比SGD省心,学习率设为0.001不需要怎么调;如果你更想要可解释性,SGD加momentum训练出来的模型权重更平滑,但学习率必须从0.01开始,太大直接发散。

训练循环建议把早停写进去,验证集loss连续5个epoch不下降就停药并保存最优权重:

import torch import torch.nn as nn device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = WaterLevelCNN(num_classes=5).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) best_val_loss = float("inf") patience_counter = 0 for epoch in range(30): # 训练阶段 model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() # 验证阶段 model.eval() val_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) val_loss += criterion(outputs, labels).item() _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_val_loss = val_loss / len(val_loader) val_acc = correct / total # 早停判断:验证loss连续5个epoch没创新低就停 if avg_val_loss < best_val_loss: best_val_loss = avg_val_loss patience_counter = 0 torch.save(model.state_dict(), "best_model.pth") else: patience_counter += 1 if patience_counter >= 5: print(f"Early stop at epoch {epoch}, val_acc={val_acc:.4f}") break

这里的核心是model.train()和model.eval()切换。PyTorch里Dropout只在训练模式生效,eval模式下Dropout自动关闭,漏掉这一步会导致验证结果不稳定。早停保存的是best_model.pth而不是最后一轮权重,因为最后一轮往往已经过拟合了。验证集准确率不是早停的判据,用验证loss更稳妥——准确率是离散的,可能在连续几个epoch里都卡在同一数值,loss的变化更能反映模型是否还在真正进步。

4. 把训练过程落进Notebook,再整理成可复用源码包

4.1 Notebook的分段结构:从数据预览到单图推理

Jupyter Notebook在这个项目里不只是记录板,它本身就是交付物之一。评审老师和同事看Notebook的习惯是从上往下滚动,所以cell顺序必须按照"看数据→看模型→看训练→看结果"的自然流程排,不能跳。

我常用的cell组织方式:

Cell顺序内容关键输出
1环境检查torch版本、CPU/GPU可用性
2超参数集中定义epoch数、batch_size、学习率
3数据预览每类随机展示6张图,看标注是否错乱
4数据加载与增强打印train_ds.class_to_idx
5模型结构定义打印模型参数量
6训练循环每个epoch打印loss和准确率
7混淆矩阵与分类报告验证集各类别精确率/召回率
8单图推理演示上传一张新图,输出预测类别和置信度

第3个cell(数据预览)是多数人偷懒跳过、但回报率极高的一步。标注错误在训练前发现只需要改一个文件夹名,训练后发现就得重新跑一轮。预览代码用matplotlib直接展示即可:

import matplotlib.pyplot as plt import torchvision # 取一个batch看真实样本长什么样,确认水位标注没标错 images, labels = next(iter(train_loader)) grid = torchvision.utils.make_grid(images[:6], nrow=3) plt.imshow(grid.permute(1, 2, 0).numpy()) plt.axis("off") plt.show() print([train_ds.classes[i] for i in labels[:6]])

这段代码会用make_grid把6张图拼成一张大图显示。注意展示前要把tensor从CHW转成HWC格式,也就是permute(1, 2, 0),否则matplotlib会报错或者显示成奇怪的彩色条纹。如果数据显示出来是随机的色块,说明ToTensor前的图片读取有问题,通常是jpg损坏或者路径含特殊字符。

4.2 源码包里的文件组织和依赖清单

Notebook适合交互式探索和演示,但不适合直接做成交付物。整理源码包时按约定俗成的结构分目录,别人打开就知道每个文件是干什么的:

water_level_cnn/ ├── data/ # 划分好的训练验证数据 │ ├── train/ │ │ ├── 0_empty/ │ │ ├── 1_low/ │ │ ├── 2_half/ │ │ ├── 3_high/ │ │ └── 4_full/ │ └── val/ ├── train.py # 训练入口,命令行可执行 ├── predict.py # 单张图片推理入口 ├── WaterLevel_CNN.ipynb # 完整演示Notebook ├── requirements.txt # 依赖清单 ├── README.md # 项目说明和数据规范 └── best_model.pth # 训练好的权重文件

requirements.txt 按最小依赖原则写,不要随便 pin 版本,避免依赖冲突:

torch>=1.13 torchvision>=0.14 jupyter>=1.0 matplotlib>=3.5 scikit-learn>=1.0 Pillow>=9.0

README里必须写清楚三件事:数据怎么组织(目录格式)、训练怎么跑(一条命令)、推理怎么调(一条命令)。源码包这玩意儿,谁都不希望打开后发现目录结构猜不出来。最稳妥的方式是README开头直接贴一下数据目录树,跟上面的结构保持一致。

4.3 从Notebook到命令行脚本的迁移

Notebook里的 cell 代码直接复制成 .py 文件跑不通,因为Notebook依赖cell之间的变量共享状态,而脚本是按顺序从头执行的。迁移时把模型定义、数据加载、训练循环、评估函数分开写成函数,main入口统一调用。

train.py 的核心骨架:

import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms def build_loaders(data_dir="data", batch_size=32): # 训练/验证的transform定义同Notebook里保持一致 train_ds = datasets.ImageFolder(f"{data_dir}/train", transform=train_transform) val_ds = datasets.ImageFolder(f"{data_dir}/val", transform=val_transform) train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True) val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False) return train_loader, val_loader, train_ds.classes def train_one_epoch(model, loader, criterion, optimizer, device): # 训练一个epoch的通用函数,Notebook里可以直接import ... if __name__ == "__main__": import argparse parser = argparse.ArgumentParser() parser.add_argument("--data", default="data", help="数据根目录") parser.add_argument("--batch_size", type=int, default=32) parser.add_argument("--epochs", type=int, default=30) args = parser.parse_args() # ... 主流程

参数化入口是脚本和Notebook最大的差异。命令行加上--data和--batch_size,换数据集不需要改代码。但超参数不要全部做成命令行参数,只暴露最常用的三四个就够了,其余在文件顶部常量区配置,保持脚本可读性。

提示:train.py 和 predict.py 里各自维护一份transform定义容易漂移,训练时用的Normalize参数和预测不一致,模型在推理时精度会掉好几个点。稳一点的做法是把transform定义放到单独模块(比如transforms.py)里,两边import同一个函数。

5. 水位分类最容易翻车的五个坑与排查方法

这几条是实际跑这个项目高频踩的坑,每条按现象、原因、解决来写,可以直接当排查手册用。

5.1 模型记住了背景却没记住水位

现象:训练集准确率接近100%,验证集准确率也很高,但把手机拿到窗边、走廊重新拍一张,分类立刻乱掉。

原因:训练照片里瓶子永远在同一位置、同一台面、同一背景,CNN根本不需要学水面,只要学"画面中央的暖色物体"就能区分出瓶子。模型学到的是场景标签,不是水位标签。

解决:数据增强加随机裁剪和亮度扰动是最直接的;有条件的话多换几个背景、多个时间段拍摄,让背景在数据里变成无关变量。排查方法很简单——把验证集里的瓶子抠出来贴到纯色背景上再测一次,准确率掉得越狠,说明模型记背景记得越死。

5.2 水面反光让误判集中在固定类别

现象:混淆矩阵显示某一类错误特别多,比如"满瓶"常常被预测成"空瓶"。

原因:强光直射下水面变成一团白色镜面反射,水面纹理完全消失,模型看到的满瓶和空瓶在视觉上没有差异。反光位置固定,模型学到的是反射光斑的位置,而不是液位。

解决:拍摄时用散射光或者把瓶子挪到背光位置。训练侧把ColorJitter的brightness加大到0.4,并且在数据里专门混入一些带反光的样本。如果反光实在无法消除,把满瓶和高水位的边界样本多拍一些,让模型学会在局部高光下仍然用瓶身下半部分的颜色特征判断水位。

5.3 类别不平衡导致预测偏向中间等级

现象:训练出来的模型把大多数样本都预测成2半瓶,空瓶和满瓶的召回率很低。

原因:拍摄时顺手拍中间水位最省事,导致数据分布严重失衡。CrossEntropyLoss在类别不平衡时会偏向样本多的类,因为错误预测多数类的惩罚期望更低。

解决:两个方向的修正。数据层面把少数类的照片捡回来用水平翻转和裁剪多扩几倍;如果不想扩数据,在损失函数里加类别权重:

import torch.nn as nn # 按每类样本数的倒数归一化得到权重,样本少的类损失放大 class_counts = [120, 180, 220, 150, 100] # 从数据集统计得到 total = sum(class_counts) weights = torch.tensor([total / c for c in class_counts], dtype=torch.float32) weights = weights / weights.sum() * len(class_counts) # 归一化到均值1 criterion = nn.CrossEntropyLoss(weight=weights.to(device))

weights的计算逻辑是:样本数越少的类,权重越大。比如空瓶100张、半瓶220张,空瓶的loss权重就是半瓶的2.2倍,强行把模型注意力拉回少数类。注意weight要在初始化criterion时就传进去,训练中途改权重会导致优化过程震荡。

5.4 同一瓶子的照片同时进了训练集和验证集

现象:训练和验证准确率都很高,自信满满去实测新瓶子,准确率直接腰斩。

原因:按图片随机划分数据时,同一个瓶子的不同照片被拆到两侧。模型记住了瓶子外观特征(瓶身高光、贴纸纹理),验证集里恰好有同一瓶子的照片,等于开卷考试。实际应用遇到的是从没见过的瓶子,立刻露馅。

解决:按瓶子分组划分数据。拍摄时就规划好,每个瓶子单独建目录,划分时按瓶子维度切分。检查是否泄漏有个快速办法:训练结束后随机挑一张验证集图片看它的最相似训练图片,如果最相似的几张来自同一个瓶子且类别相同,基本可以确认泄漏。

5.5 推理和训练的数据预处理不一致

现象:Notebook里测试准确率95%,把best_model.pth拷到另一个机器上推理,准确率掉到70%,而且错的毫无规律。

原因:训练时用的是224x224输入加ImageNet的Normalize参数,推理代码里忘了Resize,或者直接读原图没有做归一化。模型在训练时看到的输入分布是"标准化后的224x224",推理时喂给它"原始尺寸的0-255像素值",分布完全对不上。

解决:predict脚本里一字不差地复制训练时的transform。一个常见的偷懒做法是把transform写进模型类,让模型自己管理预处理——但PyTorch官方不建议这么干,因为模型应该只管张量计算。更稳妥的方案是预测脚本里固定一个检查函数:

def preprocess_check(image_path, expect_size=224): img = Image.open(image_path).convert("RGB") assert img.size[0] >= expect_size and img.size[1] >= expect_size, \ f"输入图片宽高必须大于等于{expect_size},当前{img.size}" return img

这个断言的意义是防止小图被Resize放大后糊掉细节。224x224这个输入尺寸是速度和精度的折中,小于200精度明显下降,大于320推理变慢且对精度没有实质帮助。

6. 进阶:用Grad-CAM确认模型看的真的是水面

模型高准确率不等于模型学到了水位特征。答辩或技术评审时最怕被问一句:"你怎么知道模型不是靠瓶身标签分类的?" Grad-CAM能给出一个可视化的答案。

Grad-CAM的原理一句话说:对最后一层卷积输出的每张特征图,用目标类别的梯度做加权求和,得到每个空间位置对分类决策的贡献度,然后上采样回原图尺寸叠加显示。贡献度高的区域会呈现高亮的暖色。

不依赖额外库的手动实现:

import torch import matplotlib.pyplot as plt # 注册forward hook抓最后一个卷积层的输出 activation = {} def hook_fn(module, input, output): activation["value"] = output.detach() model.features[-1].register_forward_hook(hook_fn) # 推理一张图片,拿到目标类别的梯度 img = preprocess("test_high.jpg").unsqueeze(0) model.eval() outputs = model(img) pred_idx = outputs.argmax(dim=1).item() outputs[0, pred_idx].backward() # 对特征图加权求和,得到粗略的注意力热图 grad = model.features[-1].weight.grad weights = grad.mean(dim=(2, 3), keepdim=True) cam = (activation["value"] * weights).sum(dim=1, keepdim=True) cam = torch.relu(cam).squeeze().numpy() # 上采样到原图分辨率并叠加显示 plt.imshow(cam, cmap="jet", alpha=0.5) plt.axis("off") plt.show()

如果高亮区域集中在瓶身中上部的液面位置,说明模型确实在学水位;如果高亮区散落在桌面倒影或瓶盖贴纸上,说明训练数据里有偏差,得回去补拍或加强数据增强。这一步在课设答辩里非常加分,因为它证明的不是"我调通了一个模型",而是"我验证了模型学到了正确的东西"。

另外两个值得做的进阶验证:一是专门收集水位在25%、50%、75%边界附近的样本做测试,看看模型在模糊边界上的表现,这类样本才是实际应用里最容易出现的;二是用torch.onnx.export把模型导出成ONNX,方便后续用ONNX Runtime部署到手机或边缘盒子,导出前记得把model.eval()和dummy输入尺寸设成224。

这个项目的价值不在模型本身的复杂度,而在于它验证了一条完整路径:从零开始定类别、拍数据、训练、评估、打包交付。我自己做过一个类似的水位视觉项目后最大的教训是——数据规范永远比调参重要。后续再做类似的项目,第一天就先把拍摄规范和按组划分的目录结构定死,省下来的时间远超一开始那点"凑合着先跑起来"的省事。希望这些做法能帮你少走点弯路。

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

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

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

立即咨询