基于Python+CNN的道路坑洼识别:从数据预处理到模型部署实战

发布时间:2026/9/11 20:01:12
基于Python+CNN的道路坑洼识别:从数据预处理到模型部署实战 简介基于Python与PyTorch的CNN道路坑洼识别项目面向计算机视觉初学者和智慧交通领域开发者可用来完成路面破损图像的自动分类与检测任务。压缩包共252个文件以246张jpg图片作为数据集主体配合3个py训练与界面脚本、3个txt标注与说明文件整体仅10.19MB轻量易用。代码流程清晰依次运行01数据集文本生成制作.py即可读取各分类图片路径02深度学习模型训练.py完成训练与本地保存03pyqt_ui界面.py则可直接演示识别效果。数据集针对坑洼和正常路面做了预处理通过短边补灰边将图片统一为正方形并利用旋转角度进行扩增提升模型泛化能力训练过程中自动保存log日志记录每个epoch的验证集损失与准确率便于分析与调优。目前已有133人学习下载适合需要在本地快速搭建PyTorch图像识别流程、并结合界面直观体验效果的读者。1. 从“识别”到“可用”python-cnn道路坑洼识别项目到底在解决什么拿到一个带有“含数据集.zip”后缀的项目很多人的第一反应是马上打开训练脚本跑一遍。但道路坑洼识别这类小而专的深度学习任务真正消耗时间的往往不是“训练”而是数据对齐和推理边界图片尺寸从几百到几千像素不等标注类别可能只有两类训练集可能只有几百张却要面对阴影、油渍、井盖这些长得像坑洼的干扰。这里按一个可复现的python-cnn路线来拆解先把数据集整理成ImageFolder格式再用一个三层CNN训练二分类模型最后通过滑窗把分类器变成巡检图像上的定位工具。适合刚入门的深度学习开发者也适合要做快速原型的工程师。2. 先处理“含数据集.zip”目录规范与数据预检2.1 拿到压缩包先看目录分类任务与目标检测任务的数据组织差异解压这类压缩包后第一个动作不是点开某个train脚本而是先看目录结构。绝大多数“道路坑洼识别”数据集会按“pothole”和“normal”两个文件夹存放图片这种结构可以直接用torchvision.datasets.ImageFolder加载但也有一些zip里带的是VOC或COCO格式的XML/JSON标注那就不是二分类问题而是目标检测问题后续要用YOLO或Faster R-CNN来处理CNN分类模型无法直接用。先确认现有结构是否符合下面的约定data/ ├── train/ │ ├── pothole/ │ └── normal/ └── val/ ├── pothole/ └── normal/如果没有划分训练集和验证集或者类别文件夹命名是中文、大小写混杂我一般会先用一段小脚本统一整理。类别名统一用小写英文是因为Windows不区分文件名大小写、Linux区分如果脚本里写死Pothole换到Linux服务器上就可能漏读文件。中文路径在部分图像库和torchvision版本下也可能出现解码问题尽量避开。2.2 数据预检统计数量、尺寸与损坏图片正式开始训练前需要先回答三个问题每个类别有多少张图图片尺寸跨度有多大有没有OpenCV读不出来的损坏文件很多训练中断问题都不是模型写错而是数据目录里混入了一张损坏图片或一个非图像文件。下面这段预检脚本可以直接放进项目根目录运行import os import cv2 from collections import Counter def inspect_dataset(root): stats Counter() widths, heights [], [] for label in os.listdir(root): label_dir os.path.join(root, label) if not os.path.isdir(label_dir): continue for name in os.listdir(label_dir): path os.path.join(label_dir, name) if not name.lower().endswith((.jpg, .jpeg, .png)): print(非图片文件:, path) continue img cv2.imread(path) if img is None: print(损坏或无法读取:, path) continue h, w img.shape[:2] widths.append(w) heights.append(h) stats[label] 1 print(类别数量:, dict(stats)) if widths: print(宽度范围:, min(widths), 到, max(widths)) print(高度范围:, min(heights), 到, max(heights)) inspect_dataset(data)这段脚本用cv2.imread读取图片返回None就说明文件已经损坏这类文件应该直接删除或移出目录。统计宽高主要是为了决定后续统一缩放到多少像素如果图片大多是1200×900这种大图直接用128×128输入会丢失坑洼的边缘细节如果数据集里本身就有很多640×480的中等尺寸图片128或160的输入是够用的。同时还要留意单通道灰度图部分老式巡检相机输出的就是灰度图如果不转成三通道CNN第一层卷积会报维度错误。2.3 划分训练、验证集保证类别比例一致数据量少时验证集划分是否合理会直接影响对模型好坏的判断。常见的做法是按类别分别切分每类都取80%做训练、20%做验证这样即使“坑洼”类只有100张验证集里也能保留20张不会出现某个类别在验证集中恰好没有样本的情况。用train_test_split实现起来很简单import os import shutil from sklearn.model_selection import train_test_split def split_class(src_dir, train_dir, val_dir, test_size0.2, seed42): files [f for f in os.listdir(src_dir) if f.lower().endswith((.jpg, .jpeg, .png))] train_files, val_files train_test_split( files, test_sizetest_size, random_stateseed ) os.makedirs(train_dir, exist_okTrue) os.makedirs(val_dir, exist_okTrue) for f in train_files: shutil.copy(os.path.join(src_dir, f), os.path.join(train_dir, f)) for f in val_files: shutil.copy(os.path.join(src_dir, f), os.path.join(val_dir, f)) split_class(原始数据/pothole, data/train/pothole, data/val/pothole) split_class(原始数据/normal, data/train/normal, data/val/normal)random_state42固定随机种子保证每次运行划分结果一致后面做超参数对比时才不会因为数据分布变化而误判。这里用copy而不是move是为了保留一份原始数据做备份如果磁盘空间紧张改成shutil.move也可以。验证集一旦划分好后续调参就只在这份验证集上评估不要训练一轮就换一次划分。3. 搭建“最小可跑”的CNN网络结构选择与PyTorch实现3.1 为什么不直接上预训练ResNet道路坑洼在图像里并不是一个语义很复杂的对象它通常表现为路面颜色变深、边缘断裂、局部出现阴影块这些特征集中在很小的空间范围内3×3卷积叠加池化已经足够提取。预训练ResNet在ImageNet上更强但在这种几百张图的小样本任务里反而容易带来过拟合参数量大也意味着转ONNX后模型文件大后续部署到低算力设备会比较被动。如果需要更强的骨干CSPNet这类结构在目标检测里表现不错但用在坑洼二分类上收益有限。3.2 适用于小样本的cnn结构图三层卷积的设计逻辑下面这张表是项目里实际会用到的网络结构输入统一为128×128×3输出两个类别。选择三个卷积块而不是五个是为了让模型容量匹配数据量每个卷积块都带BatchNorm和MaxPool可以在池化前先稳定激活值的分布。层输出尺寸参数与说明Conv164×64×163×3卷积16个通道BatchNormReLUMaxPoolConv232×32×323×3卷积32个通道BatchNormReLUMaxPoolConv316×16×643×3卷积64个通道BatchNormReLUMaxPoolPool1×1×64AdaptiveAvgPool2d把特征图压缩成向量Dropout64概率0.3只在全连接前生效FC2输出normal/pothole两类logits这里的通道数从16增加到64控制的是特征抽象层次。浅层卷积学习边缘和色块深层卷积学习“坑洼阴影”的组合模式。用Global Average Pooling而不是Flatten后再接多层全连接原因是GAP直接对整张特征图取平均参数量从几万降到几十过拟合压力小很多。3.3 模型定义与训练循环import torch import torch.nn as nn class PotholeCNN(nn.Module): def __init__(self, num_classes2, dropout0.3): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 16, 3, padding1), nn.BatchNorm2d(16), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.AdaptiveAvgPool2d(1), ) self.classifier nn.Sequential( nn.Dropout(dropout), nn.Linear(64, num_classes), ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.classifier(x)模型部分不做任何花哨设计padding1保持卷积输出尺寸不变池化每层减半。AdaptiveAvgPool2d(1)输出形状是(batch, 64, 1, 1)通过view展平成(batch, 64)再进全连接。训练循环按标准PyTorch流程写def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss 0.0 for images, labels in 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) return total_loss / len(loader.dataset)criterion用交叉熵optimizer用AdamW学习率先设3e-4批次大小32。训练集和验证集都需要在DataLoader里指定pin_memoryTrueGPU训练时能减少数据搬运时间。如果显存不够把批次降到16学习率同步降到2e-4因为小批次梯度噪声更大学习率过高会震荡。4. 把训练跑稳损失函数、学习率与过拟合的关键调节点4.1 坑洼类别少时损失函数怎么调道路巡检数据里“正常路面”往往远多于“坑洼”如果直接对全量数据统计准确率模型只要全预测成normal就能拿到90%以上的正确率。这种场景下第一个要处理的是类别不均衡。最直接的方案是用加权交叉熵对小类别样本给更高的损失权重。PyTorch里只需要在构造CrossEntropyLoss时传入weight参数# 假设normal有800张pothole有200张 class_weights torch.tensor([1.0, 800 / 200]) criterion nn.CrossEntropyLoss(weightclass_weights)这里的权重数组顺序要和类别索引一致0是normal1是pothole。权重值不需要太精确按照“大类数量除以小类数量”得到大约4.0的倍数即可。加权之后模型对少数类的预测错误会产生更大的梯度训练过程中pothole类的召回率会明显改善。如果权重调得过大模型会走向另一个极端把大量正常路面误判成坑洼。所以要同时观察验证集上normal类的精确率变化。实际项目中我也会用Focal Loss做备选但它的两个超参数alpha和gamma在小数据集上不好调加权交叉熵已经是性价比最高的起点。4.2 学习率与优化器参数怎么设从3e-4和AdamW开始优化器直接用AdamW而不是Adam因为它把权重衰减从梯度更新中解耦对全连接层和卷积层都能施加更干净的L2约束。学习率设定上推荐初始值3e-4配合余弦退火做一轮完整的周期性衰减optimizer torch.optim.AdamW(model.parameters(), lr3e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingWarmRestarts( optimizer, T_010, T_ci2, eta_min1e-6 )T_010表示每10个epoch做一次完整余弦退火eta_min是学习率下限。余弦退火的好处是在训练后期让学习率自然降低不需要手动判断什么时候减半。如果训练曲线显示loss在前5个epoch里震荡不下降先检查数据加载是否正常而不是急着换优化器确认没问题后再把初始学习率降到1e-4。还有一个容易忽略的点输入图片像素值归一化是否一致PyTorch预训练模型通常要求用ImageNet的均值和方差做标准化自定义CNN可以用ToTensor()后的0~1范围但训练和推理必须保持一致。4.3 防止过拟合的三个操作小样本CNN最容易出现的现象是训练loss降到很低验证准确率却停滞这是典型的过拟合。激活函数方面ReLU在这类小模型里已经足够稳定GELU并不是必需品。真正起作用的是下面三个操作。第一训练集做轻微数据增强包括水平翻转、亮度抖动、对比度抖动、小角度旋转验证集不做任何增强只做缩放和归一化。第二Dropout放在全连接层之前概率取0.3位置比数值更重要——如果放在卷积特征提取阶段会破坏空间局部特征。第三根据验证集指标保存最佳模型而不是最后一轮权重用验证loss仍然会出现过拟合后的反弹更稳妥的方式是同时监控验证集F1分数。下面的片段展示了按F1保存模型的逻辑if val_f1 best_f1: best_f1 val_f1 torch.save(model.state_dict(), best_pothole.pth)F1计算依赖混淆矩阵下一章会给出完整代码。保存模型时建议只保存state_dict不保存整个模型对象后续部署更灵活。5. 验证集上不只看到准确率混淆矩阵与推理脚本5.1 准确率会骗人先看混淆矩阵与classification_report训练结束后很多教程只打印一个准确率这个指标放在类别比例悬殊的任务里会掩盖问题。正确做法是跑完整个验证集收集所有预测结果再看混淆矩阵和每个类别的精确率、召回率、F1。下面代码直接复用训练时的val_loadermodel.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in val_loader: images images.to(device) outputs model(images) preds outputs.argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) from sklearn.metrics import confusion_matrix, classification_report print(confusion_matrix(all_labels, all_preds)) print(classification_report( all_labels, all_preds, target_names[normal, pothole] ))argmax取logits中最大的索引作为预测类别。混淆矩阵是2×2矩阵第一行代表真实normal第二列代表预测pothole矩阵右下角数值是“真实坑洼且预测坑洼”的数量。对坑洼检测来说pothole类召回率比准确率更值得关注漏报一个坑洼可能意味着后期道路损坏加重。如果召回率低除调整类别权重外还可以把推理阈值从0.5降到0.35用更多误报换取更少漏报。5.2 保存与加载模型的两个容易翻车的细节保存模型用上面提到的torch.save(model.state_dict(), best_pothole.pth)加载时先创建模型实例再load_state_dict。这里有两个细节常出问题。第一训练环境有GPU但推理机器只有CPU直接torch.load(best_pothole.pth)会报GPU相关错误需要加map_locationcpu。第二如果训练时用了DataParallel保存的state_dict键名前面会多出module.加载时会提示键不匹配去掉前缀再加载state_dict torch.load(best_pothole.pth, map_locationcpu) if list(state_dict.keys())[0].startswith(module.): state_dict {k.replace(module., ): v for k, v in state_dict.items()} model.load_state_dict(state_dict)5.3 用一段推理脚本跑单张新图验证模型能否在训练集之外工作要看推理脚本是否独立可用。下面是predict_image的核心逻辑def predict_image(path, model, input_size128, threshold0.5, devicecpu): import cv2 import torchvision.transforms as T img cv2.imread(path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (input_size, input_size)) x T.ToTensor()(img) x T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])(x) x x.unsqueeze(0).to(device) model.eval() with torch.no_grad(): logits model(x) prob torch.softmax(logits, dim1)[0] pothole_prob prob[1].item() return pothole_prob threshold, pothole_probcv2.cvtColor这行很关键OpenCV读进来的图是BGR通道顺序不转RGB直接送模型颜色特征会错位导致精度下降。unsqueeze(0)是把单张图变成(1, 3, 128, 128)的批次形状。返回的元组里第一个值是是否判定为坑洼第二个是坑洼概率。这里的阈值threshold0.5适用于类别均衡的场景类别不均衡或对漏检敏感时建议低到0.3~0.4。6. 让分类模型做检测的活滑窗推理与ONNX导出6.1 用滑窗推理把分类模型变成坑洼定位模块训练好的CNN只能判断“这张小图里有没有坑洼”但实际道路巡检照片往往是几千万像素的宽幅图直接整图缩放会丢失坑洼的细节。常见做法是滑窗推理用128×128窗口在原始大图上按固定步长移动把每个窗口独立送进模型得分超过阈值的窗口位置就是疑似坑洼区域。步长通常取窗口尺寸的1/4到1/2步长越小检测越密但耗时成倍增加。def slide_window_predict(big_img, model, win128, stride64, threshold0.5, devicecpu): h, w big_img.shape[:2] boxes [] for y in range(0, h - win 1, stride): for x in range(0, w - win 1, stride): patch big_img[y:y win, x:x win] is_pothole, score predict_patch( patch, model, device, threshold ) if is_pothole: boxes.append((x, y, x win, y win, score)) return boxespredict_patch是对predict_image内部逻辑复用后的一个变体输入直接是ndarray而不是文件路径。输出boxes列表里每一项的格式是(左上x, 左上y, 右下x, 右下y, 概率)画框和后续统计都基于这个结构。滑窗产生的相邻窗口会有大量重叠简单处理是保留score最高的窗口并删除重叠面积超过50%的邻近窗口效果足够原型使用。6.2 脱离训练环境导出ONNX便于集成线上或嵌入式环境往往不装PyTorch把模型导出成ONNX后再用onnxruntime推理是常见的最小部署路径。导出时固定输入尺寸为128、batch维度做成动态torch.onnx.export( model, torch.randn(1, 3, 128, 128), pothole_cnn.onnx, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}, logits: {0: batch}}, opset_version11 )dynamic_axes允许推理时一次传入多张图opset_version11兼容性好旧版本onnxruntime也能加载。导出后可以用下面的代码验证import onnxruntime as ort sess ort.InferenceSession( pothole_cnn.onnx, providers[CPUExecutionProvider] ) logits sess.run([logits], {input: x_numpy})[0]推理时不再需要模型定义代码只需预处理方式和训练时保持一致。滑窗推理与ONNX导出组合起来就把最初的分类逻辑变成了一个不依赖PyTorch的巡检模块。再往后就是把输入源从单张图片换成视频抽帧或无人机图传画面这一层衔接已经足够简单。本文还有配套的精品资源点击获取