
简介面向道路养护与智能巡检场景这套基于PyTorch的CNN工程可自动识别并分类道路坑洼适合深度学习初学者、计算机视觉学习者及交通设施维护项目参考。压缩包共252个文件大小约10.19MB主要包含246张道路场景图片含正常与坑洼样本作为训练/验证数据、3个Python脚本分别负责生成数据列表、训练模型、启动PyQt界面及3个txt说明文件。已有133人学习下载可作为图像二分类任务的落地范例。代码覆盖数据预处理、数据集划分、模型训练与界面展示等关键环节预处理阶段通过短边补灰边统一为正方形并利用随机旋转扩增数据增强模型泛化能力。训练结束会自动保存权重同时输出记录每个epoch验证集损失与准确率的log文件方便对比与调参。1. 道路坑洼识别项目拆解python-cnn加数据集是套什么方案一条路面出现坑洼靠人巡检一天看不了几公里靠摄像头拍回来再一帧帧人工筛眼睛先扛不住。这个zip标题把整套解决方案压成了三个关键词python是落地语言cnn是自动提取特征的模型数据集是告诉模型「坑洼长什么样、正常路面长什么样」的全部依据。说白了这是一个用监督学习做道路图像二分类有坑/无坑的完整项目包适合正在做道路巡检、市政养护系统Demo的从业者。你能从里面拿走的不只是一段能跑的代码更是一套「怎么组织自己的数据、怎么训练、怎么判断效果」的流程。哪怕你手头图片不是这个zip里的按同样的目录和脚本改一改路径也能在原数据集上复现整个训练链路。2. CNN凭什么识别坑洼特征是怎么从像素里长出来的很多人拿到这种项目第一反应是传统图像处理能不能做能但做不干净。坑洼在真实道路上的形态极不稳定晴天是深色边缘加阴影雨天是积水反光傍晚又和沥青裂缝混在一起。你很难手工写出一组固定的阈值或边缘规则去覆盖所有情况。CNN做的事情是把「人找特征」变成「模型自己学特征」这也是它在这个任务上成为默认选项的原因。2.1 为什么不用阈值、边缘检测这类传统视觉方案传统方案的核心是给机器定规则比如「暗色像素占比超过某个值就是坑洼」。听起来直接但实际跑起来会发现规则互相打架。坑洼边缘确实暗但新铺沥青的颜色也暗裂缝和排水沟盖板同样有高对比度边缘。一旦光照变化固定阈值就失效同一个坑下午两点能识别傍晚五点半就漏检。CNN不直接依赖某个像素值而是通过卷积核在局部区域里寻找可复用的模式。训练完成之后它学到的是「坑缘的梯度变化 坑底的纹理粗糙度 周围路面的平整度」这类组合特征单一路径失效时还有其他路径兜底。对道路这种背景杂、目标形态差异大的任务CNN普遍比固定特征更扛光照变化。这不是说CNN万能而是它把「特征工程」从手工设计换成了数据驱动代价是你需要一份质量过得去的数据集。2.2 一张坑洼图在CNN里是怎么变成分类结果的可以把它拆成三步。第一步输入图像被切成多个小窗口每个窗口和一个卷积核做点积得到一张特征图。一个3x3卷积核在坑缘附近会被激活因为它学到的就是「左右两侧像素差异很大」这类模式。第二步池化层把特征图缩小保留局部最强烈的响应这样模型对坑的位置偏移不那么敏感。第三步全连接层把压缩后的特征拼成一个二维概率输出例如「有坑 0.87、无坑 0.13」最后取最大值对应的类别。实际项目里常用结构是连续两个「卷积 批归一化 ReLU 最大池化」模块再接全局平均池化或展平最后接一个输出维度等于类别数的全连接层。对道路坑洼这种目标和背景差异明显的任务浅层CNN就够用不必要一上来就堆ResNet。参数少训练快小数据集上不容易过拟合这是这类zip项目最常见的模型选型逻辑。2.3 只有几百张图时CNN还值得训练吗值得但前提是任务边界清晰。坑洼识别本质是二分类图像内容相对集中不像目标检测要同时定位多个物体。几百张图训练一个几万参数的浅层CNN通常能到90%以上的验证准确率。这个结论的前提是你把数据集划分做对不要同一路段前后帧同时出现在训练集和验证集里否则指标虚高现场一测就露馅。如果图片非常少比如每类只有几十张常见做法是加载ImageNet预训练权重做迁移学习只微调最后两层。这个zip从标题看是python-cnn多数情况下自带完整训练脚本不见得依赖外部预训练权重。我更倾向于先用自带数据集从零训一个浅层网络把baseline跑出来效果不够再考虑迁移学习。从零训练的好处是可控模型行为你心里有数出了偏差容易定位。3. 先处理数据集目录结构、标签清洗与增强参数拿到zip第一件事不是跑代码而是先检查数据集长什么样。我见过太多人直接执行训练脚本跑了一半发现数据目录对不上或者某张图片损坏导致训练中断。先把数据和代码之间的接口对齐后面几十分钟只花在等待训练上。3.1 用ImageFolder组织数据一类一个文件夹不管是自带的数据集还是你自己收集的图片最常见的组织方式是PyTorch的torchvision.datasets.ImageFolder格式。根目录下分train和val两个文件夹各自里面再按类别建子文件夹。对坑洼识别来说类别名用pothole和normal最直观。这种结构的最大好处是不用手写标签文件文件夹名就是标签。dataset/ ├── train/ │ ├── pothole/ # 有坑洼的图片 │ └── normal/ # 无坑洼的图片 └── val/ ├── pothole/ └── normal/如果你的zip里不是这个结构比如所有图片堆在一个文件夹、附带一个csv标标签那需要先转成ImageFolder。转换时留意一件事trainval划分要按拍摄路段做不要随机打散。随机打散可能让同一段路面连续帧一边进训练集一边进验证集验证分数虚高。最稳妥的做法是按文件名前缀或拍摄时间分组再整组切分。3.2 标签可靠性检查坏图、重复图和类别均衡数据和标签是从哪里来的决定了模型上限。这个环节没必要写复杂脚本做三件小事即可。第一逐张确认能正常读取并打印尺寸剔除损坏或格式异常的图片。第二算一下每类图片数量的比例如果pothole只有50张而normal有500张先考虑补数据补不了就用类别权重。第三识别重复图片同一张图改名多次出现会让模型对个别样本过度记忆。from PIL import Image import os, hashlib def quick_check(data_dir): errors [] seen {} stats {} for root, _, files in os.walk(data_dir): for f in files: if not f.lower().endswith((.jpg, .jpeg, .png)): continue path os.path.join(root, f) cls root.split(os.sep)[-1] stats[cls] stats.get(cls, 0) 1 try: img Image.open(path) img.load() if min(img.size) 64: # 过小的图基本没有有效信息 errors.append((path, too_small, img.size)) except Exception as e: errors.append((path, bad_file, str(e))) with open(path, rb) as fh: h hashlib.md5(fh.read()).hexdigest() if h in seen: errors.append((path, duplicate_of, seen[h])) else: seen[h] path print(类别数量:, stats) print(异常项数量:, len(errors)) for err in errors[:10]: print(err) quick_check(dataset)这段脚本把数据检查压缩成一次遍历读取图片确认没坏、检查最小边长是否小于64像素、用md5判断有没有完全相同的重复图。运行后先看stats如果某个类别数量稀少下一步训练时要增加该类的采样权重。duplicate_of条数多说明数据集里有同图复用需要手动挑出保留哪一份否则验证集风光现场应用就原形毕露。这个脚本是通用的换任何数据集都能复用。3.3 数据增强参数别把路面纹理增强没了数据增强的目的是让模型对拍摄角度、光照、轻微模糊更鲁棒但坑洼识别有一个特殊约束坑洼是靠边缘和纹理判断的过度扭曲图像会把这些线索破坏掉。我用得比较保守的组合是随机水平翻转概率0.5、随机亮度对比度微调幅度0.2以内、随意裁剪到224x224。不要用随机旋转太大的角度路面是平的图片被旋转90度不符合实际拍摄场景反而增加学习难度。归一化参数也要对这个坑很多人踩。ImageNet预训练模型常用的均值标准差是[0.485, 0.462, 0.406]和[0.229, 0.224, 0.225]——那是相机自然图像的统计值坑洼图片整体偏灰暗但直接用它并不是错误因为归一化只是把像素分布拉回标准范围模型自己会拟合。如果发现训练时loss降得很慢可以换成自己算的均值标准差一个简单脚本就能统计出来但不建议作为第一选择。先跑起来指标异常再回头改这层。提示:增强参数影响的是模型的泛化能力不是训练收敛速度。验证集准确率如果到95%但现场一测就掉到70%优先回头检查增强配置而不是去加模型层数。4. 最小训练脚本CNN结构、损失函数和四个必调参数数据准备完就能进训练环节了。这个项目的核心代码通常就一个训练脚本包含三件事定义模型、加载数据、跑epoch循环。我用PyTorch给出一个完整可复现的最小版本它不带任何花哨技巧优先保证你在一台普通显卡甚至CPU上都能跑通。4.1 模型结构一个自带BN的浅层CNN模型设计遵循「浅层优先」原则。道路坑洼二分类不需要特别深的网络一个四层卷积的紧凑结构足够参数量控制在几百万以内。批归一化放在每个卷积块之后作用是稳定训练允许你直接用较大的学习率。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), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))输入默认是224x224的三通道图。经过三次池化后特征图从224缩到28再用AdaptiveAvgPool2d(1)把每个通道压成一个值最后全连接输出两个类别分数。用全局平均池化而不是直接展平好处是输入尺寸即使不是严格224也能跑全连接层的输入维度永远等于128通道数。这对后面换不同分辨率的数据集很友好。BatchNorm2d是这类项目的必选项它让网络不用刻意调一个很低的学习率也能稳定收敛。4.2 训练脚本从加载数据到保存最优权重下面这个脚本把整个训练流程写完整了。你只需要把data_dir改成你自己的路径控制台就能打印出每个epoch的损失和验证准确率并在验证分数最高时保存权重文件。权重保存逻辑是关键训练末期loss还可能小幅波动但验证准确率一旦下降说明开始过拟合此时保存的best_model.pth才是你上线要用的那一版。import torch, torchvision from torch.utils.data import DataLoader from torchvision import transforms, datasets device torch.device(cuda if torch.cuda.is_available() else cpu) train_tf transforms.Compose([ transforms.Resize(256), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(0.5), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.462, 0.406], [0.229, 0.224, 0.225]), ]) val_tf transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.462, 0.406], [0.229, 0.224, 0.225]), ]) train_data datasets.ImageFolder(dataset/train, transformtrain_tf) val_data datasets.ImageFolder(dataset/val, transformval_tf) train_loader DataLoader(train_data, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_data, batch_size32, shuffleFalse, num_workers4) model PotholeCNN(num_classes2).to(device) criterion torch.nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size8, gamma0.5) best_acc 0.0 for epoch in range(30): model.train() total_loss, total_correct, total_num 0.0, 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() total_loss loss.item() * images.size(0) total_correct (outputs.argmax(1) labels).sum().item() total_num images.size(0) train_acc total_correct / total_num model.eval() val_correct, val_total 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) val_correct (outputs.argmax(1) labels).sum().item() val_total labels.size(0) val_acc val_correct / val_total if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) scheduler.step() print(fepoch {epoch1:02d} loss {total_loss/total_num:.4f} ftrain_acc {train_acc:.4f} val_acc {val_acc:.4f})脚本的逻辑不复杂但有两处值得展开。CrossEntropyLoss在PyTorch里自带softmax所以模型最后一层不要额外加softmax否则训练时数值范围不对表现是loss一开始就不降。StepLR每8个epoch把学习率减半这是给训练后期留的后手——前期Adam用1e-3大步往前走后期用小学习率精调。如果数据量很小比如只有几百张建议把batch_size从32降到16StepLR的step_size从8改成5。4.3 四个必调参数学习率、batch、epoch和早停很多人跑这种项目就是一把梭跑完发现效果差其实问题多半出在这四个参数上。学习率1e-3是Adam的常见起点但如果loss在第一个epoch以后完全不动可能学习率偏小可以试3e-3如果loss直接爆炸或变成NaN说明偏大降到3e-4重新跑。batch size影响的是梯度稳定性显存不紧张时用32最合适CPU训练建议降到8否则一个epoch要等太久。epoch数量不是越大越好。对于几百张到几千张的小数据集30个epoch足够让浅层网络收敛再多就是过拟合。更稳妥的做法是加一个早停记录当前最佳验证准确率如果连续5个epoch没刷新就停止训练并加载best_model.pth。这个逻辑在上面的脚本里没有完整实现你可以按需补上——训练循环结束后再判断best_acc的更新次数超过patience就break。提示:训练脚本跑完第一遍先别急着调参。把打印的train_acc和val_acc差拿出来看:差小于3%是正常差大于10%说明过拟合验证集落后训练集一大截。根据差值方向决定加正则还是加数据比盲目调学习率高效得多。5. 坑洼识别避坑记录五类从数据到验证的翻车现场这个方向我做过不止一次每次翻车原因都挺典型。下面五条是我认为这个项目里最高频的坑也是如果只收藏一篇文章时你最该看的部分。每一条都按现象、原因、解决来写方便你对照排查。5.1 现象训练loss持续下降验证准确率却纹丝不动原因很可能是类别不均衡。如果数据集中normal占90%网络学到的策略是无脑全预测成normal这样训练loss也在降但验证集里那部分坑洼全被漏掉。又或者是模型容量太大几千张小图上跑一个ResNet18以上的网络训练集被硬背下来验证泛化不了。解决分两步。先打印每类的召回率而不是只看总体准确率。然后给CrossEntropyLoss加类别权重反比于类别频率让少数类样本被分错时产生更大损失。实现上把criterion换成CrossEntropyLoss(weightclass_weights)其中class_weights是长度为类别数的张量。和模型结构比这件事先做。5.2 现象推理时把排水沟盖板、路肩裂缝误判成坑洼这是特征混淆问题。排水沟盖板和坑洼有相似的高对比度边缘裂缝和坑洼都有深色纹理模型学到的是「足够暗且边界足够清晰」而不是「路面上的凹陷区域」。原因多数是训练集里坑洼样本的多样性不够或者干脆原始标签就有错标把排水沟照片标成了pothole。解决思路是给模型补充难负样本。从验证集中把误判的图片挑出来单独放到训练集的normal目录里重新训练。这个方法土但有效相当于给模型上课说「这些不是坑洼」。如果误判集中在特定场景比如雨后积水反光还要检查训练集里是否缺少这个光照条件下的正样本。5.3 现象本地跑得好好的换一台机器重新训练直接NaN现象很明确loss曲线在第一个或第二个epoch直接飞到NaN。最常见的原因不是模型代码错了而是数据没跟着代码走原始图片里混入了损坏的JPEG或者某张灰度图被Resize后变成了单通道喂给三通道卷积层时数值异常。换机器之后文件系统不同读取行为不一样坏图问题就暴露出来了。解决动作是回到第3章的检查脚本对图片做一次完整的Image.open load坏图直接移出数据集。另外排查学习率如果代码里改成了lr1e-2或更大Adam也会在数据不干净时放大异常梯度。整体上先清数据再降学习率用这个顺序排查。5.4 现象测试集准确率95%用手机随手拍一张预测效果极差这是典型的分布偏移。训练集大多是车载摄像头俯拍、光照相对均匀的路面而手机随手拍的照片带有透视角度、夜间闪光灯、运动模糊。CNN对训练时见过的分布非常敏感角度和光照一变特征分布就漂了。解决这类问题要在训练阶段下手。数据增强里加入RandomRotation但角度控制在正负10度以内模拟车辆颠簸时的轻微旋转再把ColorJitter的亮度范围从0.2加大到0.3提高对逆光和阴影的鲁棒性。推理前还要对齐预处理手机图片先等比缩放再中心裁剪到224不能直接resize成224x224那样会把坑洼的形状压扁特征变形。5.5 现象训练时验证准确率奇高现场测试直接崩盘问题出在数据划分阶段。如果原始视频按帧抽图同一条路面上连续的帧差异很小随机划分会让模型「见过」验证集里的近似内容验证分数虚高。现场遇到新路面时模型发现所有特征都陌生表现大幅下降。解决方法是按路段或视频片段分组划分而不是按帧随机划分。比如图片文件名里有拍摄日期和路段编号以这些信息为分组单位把整个组的图片放进同一侧。宁可训练集少一点也要保证验证集来自模型没见过的路段。这样得到的验证分数才接近现场表现。6. 部署前的最后一关混淆矩阵评估与单张推理验证权重文件保存下来不说明项目能交付。我习惯在训练结束后做两件事第一件事是打印验证集上的混淆矩阵它比准确率诚实得多第二件事是拿两三张完全没参与训练的新图片走一遍完整推理流程确认预处理和模型加载没有脱节。混淆矩阵用sklearn.metrics.confusion_matrix一行就能出来但注意要在model.eval()和no_grad下统计否则BN层和Dropout行为不同统计结果不是真实推理表现。第二件事更关键——实际部署时不走整个训练脚本而是单独写一个推理函数它必须包含和训练时一模一样的预处理步骤。很多人在这里栽跟头训练时用Resize(256)CenterCrop(224)推理时只写了Resize(224)输入分布变了分数立刻掉几个点。def predict_image(model, image_path, class_names): img Image.open(image_path).convert(RGB) tf transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.462, 0.406], [0.229, 0.224, 0.225]), ]) x tf(img).unsqueeze(0).to(device) model.eval() with torch.no_grad(): prob torch.softmax(model(x), dim1)[0] idx prob.argmax().item() return class_names[idx], prob[idx].item()这个函数就是将来接摄像头或批量推理的雏形。需要注意unsqueeze(0)补一个batch维度很多新手漏掉这步会直接报维度错误softmax要在模型输出上做模型本身的CrossEntropyLoss不包含softmax推理时你要自己补上才能得到可解释的概率值。加载模型权重时用load_state_dict(torch.load(best_model.pth))注意一定是这个接口直接torch.load覆盖给模型变量是常见的报错来源。我个人的习惯是每次改完代码都拿同一张现场图跑一遍确认概率输出在预期范围内再进入下一轮迭代。这个动作比盯着训练指标更接近真实使用场景。希望这篇文章能帮你把这条从数据到部署的路走顺少踩几次我踩过的坑。本文还有配套的精品资源点击获取