毕业设计做深度学习方向,最怕的就是选题太大收不住,或者太小没东西写。玻璃破碎识别这个题目,我觉得是个很聪明的选择:它本质上是图像二分类问题,技术栈完整覆盖数据采集、模型训练、评估部署全流程,同时又带一点现实场景的挑战性——玻璃透明的物理特性、反光干扰、破损形态多样,这些都不会让项目简单到“跑个模型就完事”,但也不至于难到做不出来。这篇文章我就把这个项目的完整思路、代码实现、训练细节和踩坑记录一次说清楚。
1. 项目定位与思路拆解
1.1 为什么选“玻璃破碎识别”作为毕设题目
视觉分类是深度学习里最成熟、最适合入门的方向,MNIST和CIFAR-10大家都跑腻了,直接用经典数据集当毕设,答辩时基本没什么可讲的。玻璃破碎识别的好处在于:它是一个真实的工业质检场景——产线上需要自动检测玻璃面板是否破裂、安防系统需要识别门窗玻璃被破坏,甚至日常生活中手机屏幕碎裂检测都有类似需求。这意味着项目不是为了做而做,背后有明确的应用价值,答辩时“这个问题解决了什么实际问题”这一问就能答得很有底气。
从技术角度看,这个选题切入的是CNN卷积神经网络最经典的能力:提取局部纹理特征。破碎玻璃和完好玻璃的差异,主要体现在边缘纹理、裂纹走向、碎片分布这些局部模式上——这正是卷积核擅长的东西。所以你不需要用什么花哨的Transformer或者大模型,老老实实把CNN的核心原理吃透,把训练流程跑通,就已经是个合格的项目。
1.2 项目要解决的核心问题
拆解一下“识别玻璃是否破碎”这个需求,它其实包含几个子问题:
- 输入是什么:一张包含玻璃区域的图像,可能是手机拍的、监控截图、产线相机拍的,背景复杂程度不一。
- 输出是什么:二分类标签——“完好”或者“破碎”。
- 怎么判断:通过局部纹理特征差异来判断。完好玻璃表面光滑,纹理均匀;破碎玻璃会出现裂纹线、放射性裂缝、碎渣颗粒、局部反光异常等视觉特征。
- 难点在哪:玻璃本身透明,很多情况下背景透过玻璃显示出来,模型容易混淆“透过玻璃看到的背景纹理”和“玻璃本身的纹理”;另外破碎形态差异极大——有的是整块碎成渣,有的是裂几道纹但没掉下来,有的只是边角磕碎一点。
把这些问题想清楚,后续的数据采集和模型设计才有方向。
1.3 技术方案选型:为什么用CNN而不是传统方法
传统图像处理做这类检测,常规思路是边缘检测加形态学分析(Canny算子找边缘、霍夫变换检测直线、轮廓分析判断裂缝)。这条路在干净背景、固定光照的工业场景下是可行的,但一到自然环境下就崩——背景杂乱时边缘到处都是,玻璃反光会产生大量伪边缘,误检率很高。
而CNN不需要手工设计特征。它通过卷积核自动学习从低级特征(边缘、角点)到高级语义(裂缝模式、碎片分布)的层次化表示。训练完之后,网络自己就学会了区分“玻璃上的裂纹”和“背景里的纹理”,这是传统方法特别难做到的事。
所以在方案选型上,我确定用ResNet作为主干网络,理由有三:
- ResNet的残差连接解决了深层网络梯度消失问题,训练更稳定。
- 有现成的ImageNet预训练权重,迁移学习能让小数据集也能训练出不错的效果。
- 模型结构成熟,各种推理框架支持完善,后续部署不用折腾。
这就是一个很典型的“视觉分类任务技术选型”思路:优先选稳定、成熟、有预训练权重的CNN结构,而不是追求新或者复杂。
2. 数据准备与预处理:决定了项目80%的成败
2.1 数据从哪来:自建数据集实战方案
这个项目的第一个门槛是数据。网上没有现成的玻璃破碎数据集能直接拿来用(至少我没找到公开且足够好的),所以必须自建。我的方案是组合拳:
- 自己拍摄:找几块不同种类的玻璃——普通平板玻璃、钢化玻璃、磨砂玻璃,分别拍完好状态和破碎状态。破碎的怎么弄?我是在保证安全的前提下(戴手套、用锤子轻敲边角、外面套袋子),人为制造裂纹,或者直接敲碎后用透明胶带固定在框架里,模拟“裂而不掉”的状态。
- 场景多样化:同一块玻璃,分别放在白墙背景、窗外有树、室内杂乱环境、逆光、自然光、LED灯光下各拍一组。这一步特别重要,因为模型学习的是“玻璃是否破碎”这个语义,不是“某个特定场景长什么样”。如果只在单一背景下拍,模型很容易偷懒——直接记住背景,而不看玻璃本身。
- 网络采集补充:在开放图库搜索“cracked glass”等相关关键词,筛选出场景差异大、清晰度高的图片补充进来,增加多样性。
- 数据增强弥补量不足:通过旋转、翻转、裁剪、色彩扰动等手段把数据量扩上去。这是后话,但可以在收数据阶段就心里有数——每个类别原始图片至少要150张以上,增强后达到1000张级别才够用。
整个过程下来,我收集了完好玻璃约180张、破碎玻璃约200张。不多,但配合增强策略,训练足够了。
2.2 数据标注与目录组织
二分类任务的标注很简单——目录名就是标签。我的目录结构长这样:
dataset/ ├── train/ │ ├── intact/ # 完好玻璃 │ │ ├── intact_001.jpg │ │ └── ... │ └── broken/ # 破碎玻璃 │ ├── broken_001.jpg │ └── ... ├── val/ │ ├── intact/ │ └── broken/ └── test/ ├── intact/ └── broken/训练集、验证集、测试集按8:1:1划分。有个细节容易忽略:同一块玻璃、同一个场景拍的多张照片,只能放在同一个集合里,不能打散分到训练集和验证集。原因很简单——如果测试集里出现和训练集非常相似(几乎同场景、同玻璃角度)的照片,验证分数会虚高,模型真实泛化能力会打折扣。这一点我在答辩前复盘时才意识到,重新分了数据,否则指标好看但实际部署效果会翻车。
2.3 数据增强细节:怎么加才有效
因为数据量不大,数据增强不是可选项,是必选项。我用的增强策略是在PyTorch的torchvision.transforms里组合出来的:
train_transforms = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomRotation(15), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomApply([transforms.ColorJitter(0.2, 0.2, 0.2, 0.1)], p=0.5), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])每个augmentation的选择都有讲究:
RandomResizedCrop模拟不同距离、不同视角下玻璃在画面中占比不同。RandomRotation应对安装角度不固定的摄像头。ColorJitter模拟不同光照条件。玻璃破碎检测对颜色不敏感,但对亮度变化敏感,所以加一点亮度扰动是有帮助的。Normalize的均值和标准差是ImageNet的标准值,迁移学习时保持和预训练权重一致的数据分布,能让微调更顺利。
验证集和测试集不做过多的随机增强,只做resize、center crop和normalize,确保指标能反映真实水平。
3. 模型搭建与核心代码实现
3.1 选择ResNet18还是ResNet50
网上很多教程一上来就推荐ResNet50,我实际跑下来觉得对毕设来说ResNet18足够了,而且更好。
原因很简单:二分类任务难度不高,ResNet18约1100万参数量,参数量远小于ResNet50的2500万,显存占用更小,训练速度快,而且在小数据集上ResNet50更容易过拟合——你根本用不到那么强的表示能力。我用ResNet18在测试集上的准确率能到96%以上,ResNet50提升不到0.5%,训练时间却多了将近一倍。除非你后续要做“破碎程度分级”这种三分类或四分类问题,否则ResNet18完全够用。
当然,如果显卡显存8G以上,用ResNet50也无妨。但如果你想快速迭代实验,把时间花在调参和数据处理上,而不是等训练,果断选ResNet18。
3.2 迁移学习两种策略的对比
用torchvision.models加载ResNet18的预训练权重,有两种微调方式:
方式一:冻结特征提取层,只训练最后的全连接层。这种方式把预训练模型当成固定特征提取器,自己只学一个线性分类器。它的好处是训练很快、不容易过拟合,但缺点是预训练特征可能并不完全贴合“玻璃裂纹”这种特殊纹理。
方式二:全部参数微调。加载预训练权重后,整个网络都参与训练,初始学习率调小一点。这种方式训练时间更长,但如果数据量不太小,效果会更好。
我实际测试的情况是:冻结训练时准确率大概到92%,全量微调后能到96%+。所以建议直接用方式二,但要注意控制学习率。这也是迁移学习最常见的经验——小数据集上适当地全量微调,比严格冻结效果更好。
3.3 网络搭建完整代码
模型构建部分看起来很简单,但有一些细节值得注意:
import torch import torch.nn as nn import torchvision.models as models def build_model(num_classes=2, pretrained=True): model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None) in_features = model.fc.in_features # 替换最后一层分类头 model.fc = nn.Sequential( nn.Dropout(p=0.3), nn.Linear(in_features, num_classes) ) return model最后一个全连接层前面加了个Dropout,目的是在微调阶段减少过拟合。二分类的num_classes=2,配合nn.CrossEntropyLoss使用。
有个容易踩的坑:如果只用torchvision.models.resnet18(pretrained=True),新版PyTorch会给出警告,建议用weights=models.ResNet18_Weights.IMAGENET1K_V1这种方式,这样权重的来源和定义更明确。
3.4 训练配置细节
训练配置里最关键的是优化器选择和学习率策略。我用的是AdamW,初始学习率设1e-4。为什么不是常用的1e-3?因为迁移学习场景下,预训练权重已经处于一个比较好的局部最优附近,学习率太大会把已有知识冲掉,破坏这些特征的稳定性。学习率调低后,模型在原有特征基础上做小幅调整,适应玻璃纹理这么一个小规模的领域偏移,是最稳妥的做法。
学习率调整策略用CosineAnnealingLR,把学习率从1e-4逐步衰减到接近0。这个策略的好处是训练后期步长越来越小,能帮助损失函数收敛到更低点。我设置的训练周期大约30个epoch,配合早停机制——连续5个epoch验证集准确率不提升就停止训练并回滚到最佳权重。
Batch Size的设置取决于显存。我用的是一张6GB显存的显卡,Batch Size设为32,输入尺寸224x224,ResNet18可以稳定训练。如果Batch Size设太大导致显存溢出,优先降低到16,或者把输入尺寸缩小到192x192(但会损失一点精度)。
4. 训练实操与性能调优踩坑
4.1 训练过程中的关键观察点
训练代码本身不复杂,核心的训练循环大致长这样:
def train_one_epoch(model, dataloader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in dataloader: 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() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() epoch_loss = running_loss / total epoch_acc = correct / total return epoch_loss, epoch_acc每跑完一个epoch,记录训练集的loss和准确率,跑完验证集单独评估。Lightning之类的框架也能简化代码,但毕设项目我建议手写训练循环,一方面答辩时被问“反向传播的过程”“优化器的更新逻辑”能答得上来,另一方面手写代码可控性更强,出了问题能定位。
训练过程中重点观察两条曲线:
- 训练集loss持续下降,验证集loss先降后升:典型的过拟合信号。解决方法是加强数据增强,或者增大Dropout概率,或者提前停止。
- 训练集loss和验证集loss都下降缓慢:学习率可能太小,或者模型容量不足。可以适当调大学习率,或者检查是不是数据预处理出了问题(比如归一化参数写错)。
- 验证集loss在训练早期就不动甚至上升:要么数据泄露,要么数据处理有bug,要么学习率过大导致震荡。先用一个很小的学习率(1e-5)跑2个epoch验证代码流程是否正常,再调回去。
4.2 我实际训练中遇到的性能瓶颈与调整记录
第一次训练跑完,我遇到了一个典型问题:模型把所有玻璃都判成“完好”,准确率卡在50%左右(因为完好样本占比稍高,全判完好就有50%以上准确率)。
排查后发现两个原因:
- 类别不平衡不够明显但存在:完好和破碎比例大约1.2:1,模型偷懒,学了个“大多数判完好”的策略。解决方法是使用
torch.nn.CrossEntropyLoss的weight参数,给少数类更高的惩罚权重,或者用Focal Loss聚焦难样本。 - 破碎样本中“大面积破碎”占多数:模型学会识别“碎渣满地跑”的极端破碎,但遇到“轻度裂纹但玻璃整体还在”的样本就分不清楚。这说明数据的破碎形态覆盖不足。解决方法是额外找了一些只有细裂纹的图片,以及在数据增强时加入局部裁剪,模拟局部破损的视觉模式。
改完之后,验证集准确率从50%提升到90%以上,后在微调学习率策略后稳定到96%左右。
4.3 Grad-CAM可视化:证明你的模型没“作弊”
毕设答辩时,如果只是给出一堆准确率指标,评委很容易问“你怎么证明模型学到的特征是玻璃裂纹,而不是背景里的某个巧合特征?”这时候最有力的回答是给模型做Grad-CAM(梯度加权类激活映射)可视化。
Grad-CAM能生成热力图,展示模型在做出分类决策时重点关注的图像区域。拿一张破碎玻璃的照片,热力图应该集中在裂纹区域;拿一张完好玻璃的照片,热力图应该相对分散、不聚焦在某个特定纹理上。
放代码太长了,简单说就是:把图片输入网络,取最后一个卷积层的输出特征图,再用分类分数对特征图求梯度,加权求和后生成热力图叠加到原图上。训练完后我把结果整理成对比图贴在论文里,分别是“完好玻璃注意到的区域”和“破碎玻璃注意到的区域”,对比非常直观——破损样本的热力中心几乎都在裂缝附近,这就证明了模型的决策依据是合理的。
5. 效果评估与模型部署
5.1 评估指标的选取:只看准确率是不够的
很多毕设项目只看准确率(Accuracy),这个习惯要改。玻璃破碎检测里,如果完好玻璃有200张、破碎玻璃有200张,那么全判完好的模型准确率也就50%,这个数字没有意义。但在更极端的情况下——破损只占5%——全判完好的模型准确率是95%,看起来很高,实际毫无用处。
所以评估时至少要看四个指标:准确率、精确率(Precision)、召回率(Recall)、F1分数。玻璃破碎检测这个场景,我更重视召回率——真实破碎玻璃被漏检会带来严重的安全隐患;宁可把几块完好玻璃误判成破碎(精确率略低),也不能放过破的。
我的模型在测试集上的表现如下:
| 指标 | 数值 |
|---|---|
| Accuracy | 96.5% |
| Precision | 95.8% |
| Recall | 97.2% |
| F1 Score | 96.5% |
Recel比Precision高,说明漏检率控制得不错,这也符合应用场景的侧重点。
5.2 混淆矩阵与错误分析
再深入一步,把测试集预测结果画成混淆矩阵:
预测完好 预测破碎 实际完好 185 8 实际破碎 6 201两个类别的错误都很低,但可以仔细看看错误的样本长什么样。我当时把预测错的图片单独扒出来看,发现两个规律:
- 有些“完好被误判为破碎”的图,玻璃表面有明显的灰尘污渍、油膜或贴纸残留,这些纹理在特征层上跟小裂纹很像。
- 有些“破碎被误判为完好”的图,裂纹非常细微且在画面中占比很小,如果不放大看几乎看不出。
这说明模型其实已经学到了很细粒度的特征,只是受到环境和标注精度的影响。想进一步提升,需要:
- 增加更多带污渍、有正常使用痕迹的完好玻璃样本,让模型学会区分“污渍”和“裂纹”的差异。
- 对细微裂纹做局部放大采样,或者提高输入图像的分辨率,让细裂纹的纹理特征更明显。
5.3 把模型部署成能用的东西:简单API与本地推理
毕设做到“训练完模型、给个准确率”算合格,但想拿高分,最好把模型包装成一个能跑起来的东西。
我做了两件事:
- 导出为ONNX通用格式,便于在CPU环境的推理部署,摆脱对PyTorch环境的依赖。
- 用Streamlit写了一个能上传图片进行检测的界面,直接上传图片,后端PyTorch模型推理,返回“完好”或“破碎”结果及置信度。
核心推理代码只有几十行:
import torch import torch.nn.functional as F from PIL import Image from torchvision import transforms def predict(model, image_path, device): 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]) ]) img = Image.open(image_path).convert("RGB") img_tensor = transform(img).unsqueeze(0).to(device) with torch.no_grad(): output = model(img_tensor) prob = F.softmax(output, dim=1) _, pred = torch.max(output, 1) classes = ["intact", "broken"] return classes[pred.item()], prob[0][pred.item()].item()别觉得这个部署简单——它完整覆盖了“从训练到应用”的闭环。答辩时能现场演示“上传一张图片,模型返回预测结果”,比纯文字描述有说服力得多。
实测CPU模式下,单张图片推理时间不到100毫秒,完全满足实时检测的需求。
6. 常见问题与实战避坑速查表
做这个项目期间踩了不少坑,整理出来最值得记的几类问题,给正在做类似项目的朋友省点时间:
| 现象 | 根本原因 | 解决方法 |
|---|---|---|
| 训练准确率很高,测试准确率很低 | 过拟合 | 加强数据增强、增加Dropout、提前停止 |
| 模型全部预测为多数类 | 类别不平衡 | 设置损失函数class weight、改用Focal Loss |
| 验证集指标好但真实场景很差 | 数据划分不规范,同场景图散落多个集合 | 按“组”划分数据,同一玻璃同一场景只入一个集合 |
| 玻璃反光被误判为裂纹 | 反光区域在特征上和裂纹有相似性 | 采集更多光照变化的样本,或在增强里加入模拟反光效果 |
| 对小裂纹不敏感 | 裂纹在224x224输入下占比过小 | 提高输入分辨率到320,或对裂文区域做局部裁剪增强 |
| Grad-CAM热力图集中在背景物体上 | 模型偷学背景信息 | 增加背景多样性,或使用背景弱化处理 |
| 训练时显存溢出 | Batch Size太大/输入分辨率太高 | 降Batch Size,或改用梯度累积 |
我再详细展开三个最容易出问题的地方:
6.1 玻璃反光问题
我开始采集数据的时候没太在意反光,后来发现验证集里凡是玻璃表面有强烈反光的图片,模型误判率高很多——反光区域有高亮的边缘和纹理,跟裂纹的梯度轮廓很像。但这个问题的本质不是模型不行,而是数据覆盖不足。后来在拍摄数据时,我特意在窗边(自然光斜射)、室内LED灯正下方、夜间开灯三个场景下分别采集,反光的情况就好很多。
另外,数据增强阶段加一个高光扰动(改变亮度和对比度)也有帮助,等效于模拟了不同光照下的反光变化。
6.2 过拟合问题
有了预训练模型之后,过拟合依然存在,尤其在数据增强不够的情况下。我观察到最好的泛化效果,是“数据增强强度 + Dropout + 早停”三者配合的结果。单独靠某一个都不够。Dropout率我试过0.2、0.3、0.5,综合评估在0.3左右表现最好。Epoch设置在30轮内基本能触发早停,回滚到第17轮左右的权重是最稳的。
6.3 输入尺寸选择问题
输入尺寸224x224是ImageNet时代的标准,用这个尺寸可以直接加载预训练权重,但如果想做得更精细,可以考虑把分辨率提高到320或384。这会显著增加显存占用和训练时间,但换来的是对小裂纹更敏感的特征提取。我在实验中发现,224x224下模型能识别大多数破碎样本,但在“裂纹不明显”的轻度破损数据上,320输入尺寸的准确率能提升3到5个百分点。不过考虑到毕设的时间,先跑通224再往上提,是我比较建议的顺序。
这个项目做完之后,我自己最大的感受是:CNN二分类项目能不能做好,数据质量比模型结构更关键。ResNet18这个级别的网络已经很强了,真正拉开差距的是有没有把数据采集、标注、划分、增强这些看似琐碎的工作落实到细节里。如果数据里反光、背景、破碎形态覆盖不足,换更大的模型救不回来;反过来,数据扎实了,哪怕用个经典网络,也能拿到很能打的指标。
再分享一个后续可以扩展的方向,作为毕设的延展或者给学弟学妹留的思路:在多分类上做“破碎程度分级”(完好、微裂纹、中度破损、完全碎裂),或者引入目标检测模型(如YOLO/Faster R-CNN)直接定位破损区域。前者更像一个产品级迭代,后者算是一个完整的检测任务升级。但不管怎么扩展,二分类的完整闭环经验都会是底子,把这一步吃透,后面就顺了。