尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

道路坑洼检测大作业:CNN数据划分、训练调参与避坑指南

道路坑洼检测大作业:CNN数据划分、训练调参与避坑指南 简介一套面向计算机视觉课程设计与期末大作业的高分参考项目基于Python与CNN实现道路坑洼检测源自大三学期获导师认可并通过评审98分的结课设计。资源适合计算机及相关专业正在完成同类任务的学生也可供希望进行图像分类实战练习的初学者借鉴能帮助理解深度学习模型从数据准备、网络搭建、模型训练到效果评估的完整流程。压缩包共14个文件主体为11个Python脚本分别实现AlexNet与LeNet-5等经典卷积网络的结构构建、样本预测、测试比对以及辅助数据处理功能另有2个h5模型权重文件可加载后直接使用1个Markdown说明文档则简洁梳理了代码结构与运行步骤。资源整体大小为10.49MB体量轻巧、结构清晰便于快速下载与本地复现。目前已有906人学习或下载该项目对于需要高性价比课程设计素材、或是想以实际案例上手卷积神经网络道路缺陷检测的读者这份完整源码和权重文件都能提供扎实的参考与起点。1. 道路坑洼检测大作业到底在评什么数据、模型还是那95分一份“计算机视觉大作业-基于PythonCNN实现的道路坑洼检测”能拿到95分以上靠的不是模型比同学大、网络比同学深而是数据组织、训练复现和答辩逻辑这三件事没翻车。我见过太多人把精力全押在堆卷积层上最后卡在验证集不收敛、测试集泛化崩盘这类问题上。这个项目解决的是典型的图像二分类加定位问题给一张道路图像判断它有没有坑洼再用滑窗或热力图指出坑洼位置。适合正在修计算机视觉、深度学习课程需要交一份完整可跑、能讲清楚原理的大作业或者想补一个CNN实战项目的人。整条链路并不复杂准备数据、预处理、训练轻量CNN、调参、验证。难点全在细节里比如数据划分会不会泄漏、标注坐标有没有跟着缩放、类别不平衡时准确率高得有没有意义。下面按我实际做的顺序拆开讲。2. 数据准备与预处理从合成坑洼到训练集划分的三个细节2.1 数据从哪来公开数据集、自拍和合成数据怎么选做道路坑洼检测第一个卡住人的往往不是模型而是数据。大作业场景下数据来源就三种公开数据集、自己拍照、合成数据。三者的成本、质量、版权风险差距很大我一般按下面这张表来评估。数据来源优点缺点适合场景公开数据集标注规范、可直接对比类别分布偏、尺寸不统一、可能和你拍的场景差异大做 baseline、快速跑通自己拍照场景贴脸、答辩有说服力数量少、标注耗时间补充少量困难样本合成数据数量可控、负样本自由生成和真实纹理有差距缓解类别不平衡、扩充训练集大作业最可靠的组合是“公开数据打底 合成数据补坑洼多样 自拍图像做验证”。为什么加合成数据真实路面图像里“无坑”的背景类数量远大于“有坑”的正样本不做扩充模型会学成甩锅侠——对什么图都输出无坑准确率还挺高。合成不是让你从零画柏油路而是在一张正常路面上叠加坑洼的纹理特征裂纹、下沉块、暗色阴影、边缘高光。import cv2 import numpy as np import os def synth_pothole(bg_path: str, out_dir: str, idx: int): 在干净路面上贴一个椭圆暗色坑洼加裂纹和阴影 img cv2.imread(bg_path) h, w img.shape[:2] # 坑洼主体随机位置的椭圆形暗斑 mask np.zeros((h, w), dtypenp.uint8) cx, cy int(w * 0.3 np.random.rand() * w * 0.4), int(h * 0.3 np.random.rand() * h * 0.4) rx, ry int(w * 0.06 * (0.8 np.random.rand())), int(h * 0.045 * (0.8 np.random.rand())) cv2.ellipse(mask, (cx, cy), (rx, ry), 0, 0, 360, 255, -1) # 坑内颜色比周围路面暗叠加一个灰度偏置 dark np.random.randint(30, 70) img[mask 0] np.clip(img[mask 0].astype(int) - dark, 0, 255) # 裂纹沿椭圆边缘画几根随机短线 rng np.random.default_rng(idx) for _ in range(rng.integers(3, 6)): x1, y1 cx rng.integers(-rx, rx), cy rng.integers(-ry, ry) x2, y2 x1 rng.integers(-15, 15), y1 rng.integers(-15, 15) cv2.line(img, (x1, y1), (x2, y2), (0, 0, 0), 2) # 边缘阴影让坑和路面过渡更自然 cv2.GaussianBlur(mask, (0, 0), 3, dstmask) shadow cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) img cv2.addWeighted(img, 1.0, shadow, -0.15, 0) cv2.imwrite(os.path.join(out_dir, fsynth_{idx:04d}.jpg), img)这段代码的逻辑是先在干净背景上用椭圆近似坑洼再把坑内像素整体压暗加几条随机短线模拟裂纹最后用高斯模糊的 mask 做边缘过渡。关键是最后一步加了阴影后坑洼不再是一个生硬的黑色椭圆模型才学得到“渐变暗区”这种真实坑洼的纹理特征而不是死记黑块。参数里可调的三个量dark控制坑洼和路面的明暗差真实照片里这个差通常在 30 到 70 之间调太大合成图会失真rx/ry控制坑洼长宽比裂纹数量设为 3 到 5 条模拟细小龟裂。合成比例我一般控制在训练集总量的 30% 以内超过这个比例模型容易对颜色过敏感真实场景里亮色坑洼会漏检。2.2 预处理流水线resize、标准化和增强的默认配置拿到原始图像后不能直接喂给CNN原因有两个不同摄像头拍出来的图像尺寸不一致模型输入必须固定像素值分布在 0 到 255不标准化会让网络第一层的梯度震荡。常见做法是先统一 resize再做归一化最后按需增强。import cv2 import numpy as np IMG_SIZE 224 def load_and_preprocess(path: str) - np.ndarray: # OpenCV 默认读成 BGR这里转回 RGB避免后面可视化时颜色错乱 img cv2.imread(path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (IMG_SIZE, IMG_SIZE)) # 从 [0, 255] 缩到 [0, 1]再用 ImageNet 的 mean/std 做标准化 img img.astype(np.float32) / 255.0 mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) img (img - mean) / std return img尺寸选 224 而不是 128是为了能复用 ImageNet 预训练权重做迁移学习如果课程要求必须从零训练128 也能跑只是精度会低一两个点。标准化用的 mean/std 是从 ImageNet 统计来的虽然道路场景和自然图像不完全同分布但实测直接复用问题不大自己重新统计一套反而容易引入偏差。数据增强是道路坑洼检测里最容易被误解的一环。这个任务的图像约束很强路面永远在下方坑洼不会倒挂车道方向固定。因此随机旋转必须限制在正负 15 度以内水平翻转可以开颜色抖动里的亮度扰动要有但对比度和色相的扰动幅度要收小否则坑洼和普通路面的明暗对比被抹平增强反而帮倒忙。2.3 训练集划分按场景切别按文件随机切多数大作业翻车的第一个大坑就是数据划分方式。假设你从一段视频里抽了 2000 帧路面图像随机打乱后按 7:2:1 切出训练、验证、测试集这组里相邻帧可能同一段坑洼同时出现在训练集和验证集里。CNN 记性很好它记住了那个坑洼的纹理特征验证集准确率测试集也都会虚高一到答辩现场跑一张新图立刻现原形。我一般会按“场景”而不是按“文件”切同一段路、同一个拍摄时段算一个场景整个场景只能进训练集、验证集或测试集三者之一。另一条是视频抽帧时先隔 N 帧抽一张把相邻帧的强相关性打断。如果只有静态图片也要先按拍摄地点分组再划分。import os import random import shutil random.seed(42) def split_by_scene(src_root: str, dst_root: str, ratio(0.7, 0.15, 0.15)): src_root 下一级是 scene001/、scene002/...每个场景内是图片 scenes sorted(os.listdir(src_root)) random.shuffle(scenes) n1 int(len(scenes) * ratio[0]) n2 int(len(scenes) * ratio[1]) for split, part in zip([scenes[:n1], scenes[n1:n1n2], scenes[n1n2:]], [train, val, test]): for sc in part: shutil.copytree(os.path.join(src_root, sc), os.path.join(dst_root, part, sc))这里的划分是以“场景目录”为最小单位保证同一个场景的图像不会跨集合。这种划分方式短期看验证集准确率会比随机划分低一两个点但那是真实水平95分大作业评的就是这个真实感。3. CNN模型设计与代码实现轻量卷积网络为什么比VGG更合适3.1 为什么选CNN做坑洼检测而不是传统方法坑洼在图像里的表现是局部纹理突变裂缝边缘、下沉阴影、颗粒状破损。传统方法用颜色阈值加形态学操作也能做但光线一变、路面颜色偏深偏浅就失效。CNN的优势在于卷积核是在数据里学出来的它自动组合出“边缘—纹理—局部形状”的特征层级同一个模型在水泥路、柏油路、雨天积水场景里都能迁移。从原理上讲卷积层的局部连接和平移等变性正好匹配坑洼检测的本质需求坑洼长在哪个位置不重要重要的是它的局部视觉模式像不像坑。用全连接网络做这个任务不仅参数爆炸、过拟合严重而且对位置变化毫无抵抗力。3.2 网络结构设计四层卷积加全局平均池化就够很多大作业一上来就 VGG16 或 ResNet50结果训练时间以小时计显存也不够答辩时连卷积核为什么是 3×3 都说不清。一个四层卷积的轻量网络完全能解决道路坑洼二分类而且训练一个 epoch 只要几十秒调参周期短是它最大的优势。层输出尺寸参数/核说明Conv1 BN ReLU112×112×323×3, stride 2快速降分辨率减少计算量Conv2 BN ReLU56×56×643×3, stride 2增加通道数提取更抽象纹理Conv3 BN ReLU28×28×1283×3, stride 2深层特征语义信息增强Conv4 BN ReLU14×14×2563×3, stride 2最后一批高层特征全局平均池化256无参数把特征图压缩成向量全连接输出层1256→1输出坑洼概率这里刻意不叠加多个 3×3 卷积来换更深的感受野而是直接 stride 2 降采样。理由是大作业任务只有 224×224 输入四层下采样后特征图已经缩到 14×14对“是否有坑洼”这种全局二分类来说压缩到这个粒度足够反而能抑制局部噪声的干扰。3.3 核心代码PyTorch 定义轻量CNN模型框架我选 PyTorch因为它的动态图机制对调试友好forward 里每一步都能打印张量形状也方便后面做 Grad-CAM 可视化。模型定义不长关键是每一处的设计理由要能讲清楚。import torch import torch.nn as nn class PotholeCNN(nn.Module): def __init__(self): super().__init__() # 四层卷积块每层都是 Conv-BN-ReLU self.features nn.Sequential( self._conv_block(3, 32, stride2), self._conv_block(32, 64, stride2), self._conv_block(64, 128, stride2), self._conv_block(128, 256, stride2), ) # 全局平均池化把特征图压成 256 维向量 self.gap nn.AdaptiveAvgPool2d(1) # 二分类输出只有 1 个神经元配合 BCEWithLogitsLoss 使用 self.classifier nn.Linear(256, 1) def _conv_block(self, in_c: int, out_c: int, stride: int): return nn.Sequential( nn.Conv2d(in_c, out_c, kernel_size3, stridestride, padding1, biasFalse), nn.BatchNorm2d(out_c), nn.ReLU(inplaceTrue), ) def forward(self, x: torch.Tensor) - torch.Tensor: x self.features(x) # [B, 256, 14, 14] x self.gap(x) # [B, 256, 1, 1] x x.view(x.size(0), -1) # [B, 256] return self.classifier(x) # [B, 1]逻辑上_conv_block是基础组件把卷积、批归一化、激活函数打包成一块代码可读性好答辩时可以直接指着它说“每个卷积层后面接 BN 是为了稳定梯度分布”。biasFalse是因为 BN 层自带偏置项卷积层再加 bias 会造成冗余。padding1配合stride2保证特征图边长恰好减半这个 3×3 卷积加 stride 2 的组合在很多轻量网络里都能见到。AdaptiveAvgPool2d(1)是全局平均池化它把任意尺寸的特征图压成 1×1好处是不用算全连接层输入维度模型对输入尺寸的容忍度更高。最后classifier输出一个 logit不经过 Sigmoid 直接交给损失函数这是 BCEWithLogitsLoss 的标准用法数值上比“先 Sigmoid 再 BCELoss”更稳定。3.4 损失函数和优化器二分类的默认搭配任务是一个二分类最稳妥的组合是BCEWithLogitsLoss加 AdamW 优化器。BCEWithLogitsLoss 把 Sigmoid 和交叉熵合在一起计算梯度不会因为概率接近 0 或 1 而消失AdamW 相比 Adam 做了权重衰减修正配合默认的 weight_decay 能有效压住过拟合。criterion nn.BCEWithLogitsLoss() optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4)学习率 1e-3 是 AdamW 在中小型网络上的安全起点weight_decay 设 1e-4 是经验值。如果训练时验证集损失在 1e-3 附近震荡下不去把学习率降到 3e-4 再训几个 epoch如果 loss 直接发散第一反应不是改网络结构而是检查数据标准化和标签有没有对齐。4. 训练与调参让损失曲线稳定下降的三个必调参数4.1 一个能保存最佳模型的训练循环训练循环本身不复杂但大作业有个通病只存最后一轮 epoch 的模型结果早停前已经过拟合了。正确的做法是每个 epoch 结束都在验证集上算一次 loss 和 accuracy只有验证集指标刷新历史最佳时才保存权重这就是“按验证集保存最佳模型”的常规操作。import torch def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, total_correct, total 0.0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device).float().view(-1, 1) optimizer.zero_grad() logits model(images) loss criterion(logits, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) preds (torch.sigmoid(logits) 0.5).float() total_correct (preds labels).sum().item() total labels.size(0) return total_loss / total, total_correct / total def validate(model, loader, criterion, device): model.eval() total_loss, total_correct, total 0.0, 0, 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device).float().view(-1, 1) logits model(images) loss criterion(logits, labels) total_loss loss.item() * images.size(0) preds (torch.sigmoid(logits) 0.5).float() total_correct (preds labels).sum().item() total labels.size(0) return total_loss / total, total_correct / total这段训练循环有一个关键细节labels.float().view(-1, 1)把标签从 [B] 变成 [B, 1]和模型输出形状对齐否则 BCEWithLogitsLoss 会报维度不匹配。model.train()和model.eval()是 BN 层的开关训练时 BN 用当前 batch 统计量验证时用累积的 running 统计量忘记切模式是数据泄漏之外最常见的隐藏错误。训练主循环里每个 epoch 先用train_one_epoch更新权重再跑validate计算验证指标比较后决定是否torch.save(model.state_dict(), ...)。保存时不要只存model.state_dict()顺手存一个包含 epoch、优化器状态、验证准确率的 checkpoint 字典这样中断后能续训答辩时也能展示“我训练了 50 个 epoch 在第 37 个 epoch 达到最优”。4.2 三个必调参数学习率、batch size、增强强度这三个参数是大作业里最值得花时间调的东西比换网络结构影响大得多。我在复现时常用下面这张参数表做基准再根据曲线方向做微调。参数默认基准调小信号调大信号学习率1e-3验证 loss 震荡、不下降收敛太慢、曲线平缓batch size32显存不足、梯度噪声大训练不稳定、震荡明显增强强度轻度旋转 ±15°、水平翻转、轻度亮度抖动验证集过拟合严重欠拟合、训练集 loss 居高不下batch size 在 32 附近时梯度的随机噪声和更新步长比较平衡。调小到 8 可以省显存但每个 batch 的统计量噪声大BN 层会不稳定调到 128 以上单 epoch 时间变短但收敛曲线会变得非常平滑实际需要更多 step 才能达到同样的精度。经验值是先用 32 跑通再往大的方向试探前提是显存允许。4.3 损失曲线不听话三个最常见的异常和对应的处理方案训练过程中最常见的翻车现场有三个。第一个是训练 loss 降得很好、验证 loss 在某个 epoch 后开始反弹这是典型的过拟合此时优先调大增强强度、加 Dropout而不是删模型层。第二个是 loss 从头到尾几乎不动这种情况九成是学习率过大导致梯度反复在最优值附近震荡或者权重初始化出了问题先把学习率降到 1e-4 试试。第三个是验证 loss 上下乱跳完全不收敛多半是数据预处理不一致——训练时做了标准化、验证时忘了做几乎必然表现为这种抖动。针对后半个训练周期我习惯加一个学习率自动衰减让模型在前半段大步搜索、后半段小步精调比固定学习率硬跑到 50 个 epoch 稳定得多。from torch.optim.lr_scheduler import ReduceLROnPlateau scheduler ReduceLROnPlateau(optimizer, modemin, factor0.5, patience5) # 每个 epoch 验证结束后调用 # scheduler.step(val_loss) # 如果 val_loss 连续 5 个 epoch 不下降学习率自动减半modemin表示监控的指标是越小越好的 losspatience5意味着连续 5 个 epoch 没有刷新最优 loss 才触发衰减factor0.5是每次衰减一半。对 224×224 的小图像任务这个配置基本能让曲线在 40 个 epoch 内稳定收敛到平台区。5. 五个必踩的坑从数据泄漏到玄学过拟合5.1 数据泄漏训练集准确率高到不真实现象训练集准确率 99%验证集准确率也有 97%但答辩现场随便拍一张新图模型判断错误率明显偏高。原因划分数据时按文件随机切而不是按场景切。同一段视频的相邻帧几乎一模一样模型“见过”了验证集的图像内容。解决改用按场景目录划分的方式视频抽帧先隔 N 帧采样打断相关性。5.2 标注框和图像一起缩放坐标没跟着变现象如果大作业要求输出坑洼的位置信息用 resize 预处理后再画框框和坑对不上全部偏到一边。原因cv2.resize只作用于图像矩阵标注坐标没有按缩放比例同步换算。解决所有几何变换resize、随机裁剪、水平翻转必须同时作用于图像和坐标不该把预处理和标注拆成两套独立流程。一个简单的检查方式每个 epoch 跑一遍把 batch 里的图像和框画出来存到本地肉眼看 20 张不偏就可以继续。5.3 类别不平衡95% 的准确率其实全在猜“无坑”现象验证集准确率 95%看起来很高但查看分类明细模型把所有样本都预测为“无坑”有坑的样本一个都没抓住。原因数据集里背景类占比超过 90%模型只要全猜负样本就能拿到高分准确率这个指标在这种分布下完全失去了意义。解决不要只看准确率用混淆矩阵算召回率训练层面可以用加权采样让每个 batch 里有坑和无坑的比例接近 1:1或者改用 Focal Loss 让模型把注意力放到难分类的正样本上。对一份大作业而言在答辩时主动展示混淆矩阵和召回率比甩一个虚高的准确率有说服力得多。5.4 验证集指标最好的模型没被存下来现象训练到第 30 个 epoch 时验证集指标最佳但最后保存的是第 50 个 epoch 的权重指标明显下降。原因训练脚本只保存最后一个 epoch 的模型或者保存条件写的是“每轮都存”导致最佳权重被覆盖。解决每次验证结束都对比一次历史最佳指标只有刷新才保存。检查保存条件是否生效最简单的方法是训练完后打印日志里的最佳 epoch 编号和最终加载的模型是否一致。5.5 数据增强用力过猛坑洼特征被增强掉现象加上随机旋转 90°、左右翻转、强颜色抖动后训练集 loss 反而降得比之前慢验证集指标也变差了。原因道路图像有很强的先验约束旋转 90° 后路面方向颠倒坑洼阴影的朝向完全失真。模型学到的是各种被拉伸变形的奇怪纹理和真实坑洼对不上。解决增强强度要贴合任务场景——随机旋转限制在正负 15 度以内颜色扰动只对亮度做轻微调节不做重度对比度变化且增强只在训练集开验证和测试都要用原始图像。6. 验证与进阶用混淆矩阵和Grad-CAM把答辩做出深度6.1 混淆矩阵和单类别指标训练完成后别只盯着验证集的 overall accuracy把混淆矩阵打出来统计每个类别的 precision、recall、F1。坑洼检测的难点从来不是把无坑认成有坑而是把有坑漏掉。一张有坑的图被漏检测在评卷人眼里比十张误报都致命。from sklearn.metrics import confusion_matrix, classification_report # preds 和 labels 都是从测试集收集的 0/1 数组长度一致 cm confusion_matrix(labels, preds) print(classification_report(labels, preds, target_names[无坑, 有坑]))6.2 Grad-CAM 热力图模型凭什么是这个答辩时最怕被问“模型凭什么判断这里有坑”Grad-CAM 是解答这个问题的最直接工具。做法不复杂取模型最后一个卷积层输出的特征图用分类得分的梯度做加权求和再归一化成热力图叠加到原图上。热力图高亮区域如果集中在坑洼周围说明模型学到的特征有语义意义如果高亮区域散布在图像边缘说明模型可能在靠背景特征做判断需要回头检查数据或增强。6.3 有余力时怎么升级分割头和迁移学习二选一如果 50 个 epoch 后还想再提高一两个点两个方向选一个即可。一是把输出改进成分割头用特征图直接预测每个像素是不是坑洼从二分类升级成像素级定位二是做迁移学习加载 ImageNet 预训练的 ResNet18 主干冻结前几层只训练后面几层收敛更快且通常能提升一到三个百分点。前者适合想冲高分的后者适合时间紧的。我自己带项目时曾经因为数据划分不严谨白跑了一周后来养成一个习惯任何训练开始前先花半小时画清楚数据流动图确认每一张图像只出现在一个集合里再去碰模型代码。这个习惯让后面所有调参工作都建立在可信的验证指标上。大作业拿高分没有玄学把数据组织好、把模型选小、把过程讲清楚分数自然就上去了。希望帮到你。本文还有配套的精品资源点击获取
返回列表