PyTorch+CNN道路坑洼检测大作业:从数据到答辩全攻略 简介面向计算机视觉或相关专业的课程设计或期末大作业场景这是一份采用Python与CNN实现道路坑洼检测的完整源码包特别适合需要快速上手项目实战或借鉴高分作业做法的学习者。整套项目源自作者大三学期的期末作业经导师指导认可评审达到98分代码模块划分清晰注释也较为完善可直接作为同类型任务的参考框架。压缩包共含14个文件其中11个Python脚本覆盖数据预处理、模型构建、训练及预测等核心环节并涉及AlexNet、LeNet-5等经典网络结构的实现2个h5文件为已训练好的模型权重可直接加载复用1个Markdown文档用来说明整体设计与使用方法包总体积约10.49MB。目前已有906人学习过本资源下载后可通过该工程快速理解CNN在道路坑洼检测中的实际应用并基于现有代码开展二次开发与调优。1. 道路坑洼检测大作业CNN方案为什么是拿高分的最短路径计算机视觉课程的期末大作业里“基于PythonCNN的道路坑洼检测”是一个高频选题——场景直观、数据好找、效果容易演示而且恰好落在CNN分类和目标定位两个核心知识点上。很多同学拿到源代码包的第一反应是赶紧跑通结果卡在环境配置和参数调试上就是一整晚最后只能提交一个“能跑但讲不清”的版本。这篇笔记把从数据准备、模型搭建、训练调参到答辩汇报的完整路径拆开讲代码可以直接抄参数给的是我反复试过的起点值不是玄学。适合正在赶大作业的学生也适合想在报告里多写一两个亮点的从业者——前提是你已经装好了Python3和PyTorch用CPU跑完全足够只是比GPU慢上几倍。下面按“数据怎么做、模型怎么搭、参数怎么调、坑怎么躲、分怎么再涨”的顺序走一遍。2. 数据集与输入管线坑洼检测任务的第一公里2.1 数据来源与标注公开资源、手机实拍、Labelme怎么配合道路坑洼检测的数据来源一般有三条路公开数据集、手机或行车记录仪实拍、视频抽帧。公开数据方面Kaggle上有多个Pothole Detection相关数据集学术项目也会放出一些坑洼图像集。但直接拿来用有个典型问题——视角和光照不统一。有的数据集是无人机俯拍有的是行车记录仪视角有的甚至是从新闻图片里截出来的混合训练会让模型学得一头雾水。我的习惯是先用公开数据做一次快速验证所有图片缩放到224x224训练一个十几轮的小模型如果验证准确率连60%都不到说明数据分布和你的目标场景差异太大必须掺入实拍图。手机实拍是最可控的来源也最容易让答辩老师认可。推荐在三种光照下拍摄顺光、侧光、逆光。坑洼在侧光下阴影最明显CNN也最容易学到“阴影加纹理断裂”的组合特征逆光样本太少会导致模型出现系统性误检——把所有阴影都报成坑。每类场景拍30到50张加上公开数据扩充到正负样本各250张以上一个轻量CNN就能学到可演示的效果。标注环节纯分类任务用文件夹目录就够了background和pothole两个文件夹各放对应图片。如果你计划做第6章的定位热力图或分割分支推荐用Labelme做多边形标注输出JSON后自己写转换脚本。LabelImg也可以它输出Pascal VOC格式的XML转掩码时稍微绕一点。我一般直接选Labelme因为JSON转掩码的代码很成熟改改路径就能用。2.2 图像预处理与数据增强小样本提分的第一个杠杆图像预处理在坑洼检测里被很多人轻视。不同设备拍出的图片分辨率和曝光差异很大统一管线是读取图片、转RGB、缩放、归一化。归一化的均值方差直接沿用ImageNet的(0.485, 0.456, 0.406)即可虽然你的数据不是ImageNet但后续如果要换预训练权重做对比实验这会省掉很多麻烦。数据增强是这份大作业里性价比最高的操作。坑洼检测的典型增强组合是随机水平翻转、小角度旋转、亮度饱和度抖动、随机裁剪缩放。注意旋转角度别超过15度坑洼本身有路面朝向转太多会让模型学到错误的空间先验。下面是用PyTorch写的数据加载器import os import cv2 from torch.utils.data import Dataset from torchvision import transforms class PotholeDataset(Dataset): def __init__(self, root_dir, trainTrue): self.root_dir root_dir self.train train self.image_paths [] self.labels [] class_names [background, pothole] for label, cls in enumerate(class_names): cls_dir os.path.join(root_dir, cls) for fname in os.listdir(cls_dir): if fname.lower().endswith((.jpg, .jpeg, .png)): self.image_paths.append(os.path.join(cls_dir, fname)) self.labels.append(label) if train: self.transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.3, contrast0.3), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) else: self.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]) ]) def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img cv2.imread(self.image_paths[idx]) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) label self.labels[idx] if self.transform: img self.transform(img) return img, label这里面有一个细节cv2读图默认是BGR通道必须手动转成RGB否则模型会学到错误的颜色分布。RandomRotation(10)表示正负10度区间亮度抖动0.3是常见起点如果数据偏暗可以调到0.5。训练集和验证集共用归一化参数但验证集不能加随机增强否则评估结果每次都不一样。2.3 划分数据集目录结构、随机种子与文件级划分数据划分是整份代码里最容易埋雷的环节。常见错误有两种一种是不固定随机种子每次运行划分结果不同训练过程完全不可复现另一种是按文件名字母序切分很可能背景图片集中在前半段、坑洼图片集中在后半段划分出来的训练集和验证集分布严重失衡。正确做法是按类别分层划分每个类别内部先随机打乱再按比例切出训练、验证、测试三份。下面的代码按类别分层做文件级划分并把结果复制到独立目录中import random import shutil from pathlib import Path def split_dataset(src_dir, out_dir, train_ratio0.7, val_ratio0.15, seed42): 按类别分层划分数据集保证每个类的比例一致 random.seed(seed) class_names [background, pothole] out_dirs {split: {cls: Path(out_dir) / split / cls for cls in class_names} for split in [train, val, test]} for split in out_dirs: for cls in out_dirs[split]: out_dirs[split][cls].mkdir(parentsTrue, exist_okTrue) for cls in class_names: src_class Path(src_dir) / cls images [p for p in src_class.iterdir() if p.suffix.lower() in (.jpg, .jpeg, .png)] random.shuffle(images) total len(images) n_train int(total * train_ratio) n_val int(total * val_ratio) for split_name, split_imgs in [ (train, images[:n_train]), (val, images[n_train:n_train n_val]), (test, images[n_train n_val:]) ]: for img in split_imgs: dst out_dirs[split_name][cls] / img.name shutil.copy2(str(img), str(dst)) if __name__ __main__: split_dataset(raw_data, dataset, seed42)这段代码的关键点有三个先固定seed再shuffle保证可复现按类别分别shuffle避免某一类全部落入训练集划分粒度是文件而不是样本条目杜绝同一场景的连续帧被拆到不同集合里。val和test各取15%是我在小样本任务上的经验值总样本500到1000时这个比例比较稳。还有一条纪律调参过程中只看val测试集留到最后 evaluate 一次反复用测试集调参会让它失效。3. CNN模型搭建与训练从零写出一个能答辩的结构3.1 选型理由为什么是CNN而不是Canny边缘检测或YOLO选型这一步必须先说清一个误区很多人听到“检测”就想到YOLO但课程大作业里的“道路坑洼检测”最主流的做法其实是“图像分类”——判断一张图里有没有坑洼。YOLO的边界框输出是加分项不是必选项。YOLO的anchor机制、NMS和损失函数对初学者是个黑匣子调一组能用的参数可能要搭上一整天小数据集上还容易不收敛答辩时被追问细节很容易露怯。传统图像处理方案比如Canny边缘检测加轮廓分析在某些光照条件下确实能圈出坑洼但鲁棒性很差。路面裂缝、车道线、轮胎印乃至井盖边缘都会产生大量伪轮廓换一个场景准确率就崩。CNN的价值在于它不依赖手工设计的特征而是让卷积核在训练中自己学习坑洼的视觉模式——阴影形状、边缘断裂、路面纹理异常的组合。在我做过的项目里500到1000张的小数据集一个轻量CNN从头训练就能达到85%以上准确率用ImageNet预训练权重微调90%以上是常规水平。那为什么不直接调torchvision.models.resnet18可以用而且效果不错。但大作业的评分点通常包含“对模型结构的理解”。从零搭一个简单的CNN在报告里画出结构图、说明每层的感受野和参数量比直接调用ResNet更能体现课程内容的掌握程度。我的建议是主模型用自己写的轻量CNN对比实验里再加一个ResNet18做baseline这样报告既有深度又有广度。3.2 模型结构轻量CNN的层设计、感受野与参数说明下面这个结构是坑洼检测任务上我反复使用的基础版本四组卷积加一个分类头输入224x224的RGB图像。设计时考虑三个点前几层用3x3小卷积核堆叠等效感受野足够捕捉坑洼的边缘和局部纹理每层紧跟BatchNorm在小batch size下训练稳定性明显更好分类头用AdaptiveAvgPool2d模型不锁定输入尺寸答辩现场换测试图不用改网络。import torch.nn as nn class PotholeCNN(nn.Module): def __init__(self, num_classes2): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Dropout(0.5), nn.Linear(256, 128), nn.ReLU(inplaceTrue), nn.Linear(128, num_classes) ) def forward(self, x): x self.features(x) x self.classifier(x) return x参数说明输入3通道RGB图经过四次池化特征图从224降到14通道数从32升到256。这个结构的总参数量约300万CPU上单张推理在50毫秒以内。Dropout放在第一个全连接之前比例0.5对这个规模的模型不算激进。如果训练准确率接近99%而验证集只有80%把Dropout提升到0.6或者给特征层加一点L2正则过拟合能明显缓解。3.3 训练主循环损失函数、优化器与学习率调度训练部分的工程素养主要体现在三个选择上二分类用CrossEntropyLoss内部包含softmax优化器用AdamW而不是Adam因为AdamW对weight_decay的处理更规范小数据集上不容易出现权重衰减失效的问题。学习率从1e-3起步配合余弦退火调度通常比固定学习率高出两到三个点的验证准确率。import torch from torch import nn, optim from torch.utils.data import DataLoader def train_model(model, train_loader, val_loader, epochs30): device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs, eta_min1e-5) best_acc 0.0 for epoch in range(epochs): 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() val_acc evaluate(model, val_loader, device) scheduler.step() print(fEpoch {epoch1:02d} | loss {running_loss/len(train_loader):.4f} | val_acc {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_pothole_model.pth) def evaluate(model, loader, device): model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return correct / total这里三个核心细节容易被忽略。第一保存模型用state_dict而不是整个model对象加载时先实例化结构再load_state_dict代码重构后旧权重依然可用。第二CosineAnnealingLR的T_max设为总轮数学习率从1e-3平滑降到1e-5比阶梯式下降更适合小数据集。第三evaluate里的model.eval()和torch.no_grad()必须成对出现漏掉任一个验证集指标都会虚高。训练中还要留意如果训练准确率已经很高但验证准确率不再上涨说明过拟合已经发生应该用触发“验证集最佳”时保存的权重而不是最后一个epoch的权重——上面的代码里已经实现了这个逻辑。4. 训练与调参从85分到95分关键在参数和评估4.1 超参数起点值一张参数表和一个调整策略很多人拿到训练代码后第一件事是照抄参数然后发现loss不降就开始怀疑代码有问题。实际上超参数必须和你的数据规模匹配。坑洼检测这个任务下面这组参数是我验证过比较稳的起点参数建议起点值调整方向输入尺寸224x224坑洼在图中占比很小时升到288batch size16CPU/ 32GPU显存不足时降到8同时学习率减半学习率1e-3AdamW训练震荡就降到3e-4epochs30验证集停止上升就早停weight_decay1e-4过拟合明显时加大到1e-3Dropout0.5过拟合再加到0.6优化器AdamW不推荐SGD收敛太慢调整策略有一条铁律一次只动一个变量。有人同时改学习率、batch size和数据增强结果准确率下降了却不知道是哪个改动导致的。我的习惯是先用这张表跑一遍记录train_acc和val_acc。如果train_acc低于85%优先调学习率和epochs如果train_acc接近99%而val_acc低于90%优先调Dropout、weight_decay和增强强度。epochs设成30只是起点实际中验证集往往在第12到20轮就达到峰值之后继续训练只是让train_loss继续下降val_acc不再上涨——那是过拟合的开始。报告里一定要说明你选的是验证集最优那个epoch的模型。4.2 类别不均衡坑洼样本不够的时候怎么办道路坑洼检测里正负样本不均衡是常态。外出实拍时背景图随便拍都是坑洼图却要专门去找结果pothole目录只有80张background却有三四百张。直接用原始分布训练模型会把所有图片都判成background准确率照样有70%以上——但这是假指标掩盖了模型完全没有检测能力的事实。解决不均衡有四种办法按性价比排序。第一是给损失函数加类别权重PyTorch的CrossEntropyLoss直接传weight参数代码只加几行效果立竿见影。第二是数据增强时对少数类做额外增强比如对pothole图多做随机裁剪和亮度变化。第三是过采样少数类样本。下面这段代码演示了类别权重的计算from collections import Counter import torch from torch import nn # all_train_labels 是训练集所有样本标签组成的列表 label_counts Counter(all_train_labels) total sum(label_counts.values()) num_classes 2 weights [total / (num_classes * label_counts[i]) for i in range(num_classes)] criterion nn.CrossEntropyLoss( weighttorch.tensor(weights, dtypetorch.float32).to(device) )权重计算逻辑是每类的权重等于总样本数除以类别数乘该类样本数。假设background有300张、pothole有100张两类权重分别是0.5和1.5坑洼类的梯度贡献被放大三倍。加权重后训练准确率可能会从95%降到90%左右但验证集上pothole的召回率会明显升上来——这才是我们要的指标。补充一点过采样不能直接把重复图片复制进目录再shuffle因为复制之后再做划分同一图片的副本可能同时进入训练和验证集造成数据泄漏。正确顺序是先用第2章的split_dataset划好目录再对train目录里的少数类别做增强扩展。欠采样删掉部分background图在小数据集上不推荐有效样本变少后模型学不到稳定的背景分布。4.3 评估与可视化报告里放什么图才能拿高分大作业报告里只写一行“准确率93.2%”是最吃亏的呈现方式。评估环节至少要做三件事混淆矩阵、按类别的精确率与召回率、Grad-CAM热力图。混淆矩阵能暴露模型的失败模式——如果pothole的召回率低于85%说明漏检严重如果background被误判为pothole的样本很多说明模型的假阳性需要压制。from sklearn.metrics import confusion_matrix, classification_report import matplotlib.pyplot as plt y_true, y_pred [], [] model.eval() with torch.no_grad(): for images, labels in test_loader: images images.to(device) outputs model(images) preds outputs.argmax(dim1).cpu() y_true.extend(labels.numpy()) y_pred.extend(preds.numpy()) cm confusion_matrix(y_true, y_pred) print(classification_report(y_true, y_pred, target_names[background, pothole])) plt.figure(figsize(6, 5)) plt.imshow(cm, cmapBlues) plt.colorbar() plt.xticks([0, 1], [background, pothole]) plt.yticks([0, 1], [background, pothole]) plt.xlabel(Predicted) plt.ylabel(True) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi200)训练曲线是另一个必备图。在train_model里多收集两个列表train_loss_list和val_acc_list每个epoch追加一次最后用双子图画出来。曲线图的价值在于向老师展示你看到了过拟合的发生过程——训练loss持续下降、验证准确率在第15轮左右见顶于是你选择了第15轮的权重。报告的结论必须能追溯到图上的现象而不是空口说“模型效果好”。5. 避坑指南道路坑洼检测大作业的5个翻车现场与排查方法5.1 现象训练时报CUDA Out of Memory代码没有CPU回退大作业的常见配置是实验室机器有GPU但显存只有4GB。模型和数据稍大一点训练就报CUDA Out of Memory代码直接崩掉。原因通常是batch size设得太大或者代码里没有先检测设备再分配参数。解决方法是先判断设备再设置batch size同时显存不足时逐级降batch size并同步降学习率。batch size从32降到16再降到8学习率从1e-3降到5e-4否则梯度估计的噪声变大训练容易震荡。更彻底的做法是把图像尺寸从224降到160特征图尺寸变小显存占用能省下一半。注意不要在GPU机器上把batch size调到显存刚好装下的程度训练时显存占用会波动留出20%余量更稳。5.2 现象验证准确率虚高答辩现场用新图一测就翻车这是最常见的高分翻车现场报告里写验证集95%答辩现场拿手机拍了一张路面照片模型直接判成background。大概率是数据泄漏——划分时没有按类别分层或者测试集和训练集混入了同一段视频的相似帧。连续视频帧之间的相似度极高如果同一场景的帧被拆到train和test两个集合测试集评估的就是“见过的数据”而不是泛化能力。解决办法是视频抽帧时至少相隔10帧取一帧然后按拍摄事件分组划分确保同一组视频帧只出现在一个集合里。还有一个自查方法训练结束后从训练集随机抽10张图预测准确率应接近100%如果训练集准确率也不高那是欠拟合而不是数据泄漏。5.3 现象模型把路面阴影全部报成坑洼坑洼检测最典型的误检来源就是阴影。沥青路面上的裂缝阴影、树荫、轮胎印的边缘在CNN看来和坑洼的视觉特征高度相似。解决这个问题光增加负样本不够负样本里要专门包含“带阴影的正常路面”这一类。我在第2章提到逆光拍摄就是为了制造这类“欺骗样本”。把逆光下的正常路面、树荫下的路面、雨后倒影路面的图片全部归入background让模型必须区分“阴影加边缘断裂”和“只是阴影”的差异。另一个有效做法是数据增强里加入随机亮度扰动范围调到0.3以上强迫模型不依赖绝对亮度而依赖形状特征。预测时可以加一个启发式后处理坑洼区域的长宽比应该在一定范围内过大或过小的高亮连通域直接过滤——但这只是治标报告里要写明它是“启发式后处理”。5.4 现象预测脚本与训练时的预处理不一致训练时用224x224输入预测时直接用原始分辨率模型输出的置信度莫名其妙甚至把坑洼图判成背景。预处理不一致是新手最容易忽略的细节因为代码不报错只在结果上悄悄体现。解决办法是把transform定义成独立的模块训练脚本和推理脚本都从同一个文件import而不是各自写一套。尤其注意两点归一化的均值方差是否一致以及有没有做RGB通道转换。还有一个隐蔽问题验证集评估用DataLoader测试集预测却写了另一个手写循环两套代码路径的缩放和归一化细节容易出现微小差异。统一入口是根治手段。5.5 现象报告里只有accuracy一行字没有过程可视化这一条和代码无关但对分数的影响比想象中大。很多人的训练过程只有控制台输出的loss和一行最终准确率报告里没有训练曲线、没有混淆矩阵、没有预测样本展示。老师拿到这样的报告无法判断你是否真正理解了这个任务——他能推断的只是“你跑通了别人写好的代码”。拿高分不需要额外算力只需要在每个epoch记录train_loss和val_acc画一条曲线在测试集上挑出四到六张典型图片把预测标签和置信度打在图上把第4章的混淆矩阵和第6章的热力图放上去。从“我跑了一个模型”到“我分析了一个模型”分数差距就体现在这些细节里。提示这一章的所有排错经验前提都是你已经在数据划分时固定了随机种子。随机种子不固定所有曲线对比和验证集指标都不可复现也就谈不上排查。6. 从95再往上走给分类模型加一个弱定位分支6.1 用Grad-CAM生成热力图让模型说清楚自己在看哪里分类模型只输出一个标签答辩老师最常问的问题就是“你怎么知道模型是根据哪个区域判定有坑的”Grad-CAM热力图是这个问题最直接的回应。它的原理是求分类得分对最后一个卷积层特征图的梯度按通道加权得到空间重要性图不需要改动模型结构、不需要额外标注。def grad_cam(model, input_tensor, target_class): 简化版Grad-CAM返回与输入等宽高的热力图 model.eval() input_tensor input_tensor.unsqueeze(0).requires_grad_(True) features model.features(input_tensor) output model.classifier(features) score output[0, target_class] score.backward() grads input_tensor.grad.abs().mean(dim1, keepdimTrue) cam (features.detach().mean(dim1, keepdimTrue) * grads).clamp(min0) cam torch.nn.functional.interpolate(cam, sizeinput_tensor.shape[2:], modebilinear, align_cornersFalse) return cam.squeeze().numpy()这个简化版避开了逐通道梯度统计的高计算量直接用输入梯度做近似热力图形状已经足够支撑“模型关注的是坑洼区域”这种定性结论。答辩时把原图、预测标签、热力图三张图并排摆在一页slide上比任何文字都有说服力。注意模型里有Dropout热力图生成前必须保持model.eval()否则每次生成的结果都会抖动。6.2 从分类到定位加一个分割头输出像素级掩码如果还想再增一个亮点可以在训练好的分类模型上另加一个小分支对输入图片输出二值掩码标注坑洼的像素位置。不需要重新训练整个网络——冻结特征提取层只训练解码部分。常用做法是取分类模型里256通道的14x14特征图用转置卷积逐步上采样到224x224再接一个1x1卷积输出单通道logits损失用BCEWithLogitsLoss。训练几百轮后生成的掩码精度谈不上完美但作为“弱监督定位对比实验”放进报告已经足以说明你理解了语义分割的基本思路。6.3 一个真实的边界案例视频连续帧暴露了井盖误检我在自己的坑洼检测项目里最后用行车记录仪视频做了一次连续帧验证。结果模型对排水井盖产生了连续误报——井盖的圆形金属边缘和阴影让模型误判成坑洼。这个现象本身成了答辩时最有价值的素材我能清楚说明模型的边界在哪里并给出两个改进方向——增加“井盖”作为第三个负类别重新训练或者在数据增强里加入圆形纹理的负样本。这类大作业拿到95分以上靠的不是代码多花哨而是每一步都有依据、每个结果都有可视化、每个失败都能解释清楚。我的习惯是在提交前把整个项目从空目录开始重跑一遍确保不依赖绝对路径、不需要手动修改代码、所有依赖都有记录。走过的坑越多报告里的分析就越扎实。希望这条路径能帮你少走几天的弯路祝你答辩顺利。本文还有配套的精品资源点击获取