ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SSD+VGG16道路损坏检测实战:从数据标注到TensorRT部署

SSD+VGG16道路损坏检测实战:从数据标注到TensorRT部署 简介基于PyTorch结合SSD与VGG16骨干网络的道路损坏检测Python源码面向深度学习入门者、计算机相关专业学生及毕业设计开发者可用于路面破损检测模型的训练、验证与推理。压缩包共7个文件包含5个Python脚本分别覆盖配置管理、模型训练、效果评估、单张图片检测与视频检测另附README说明和License授权说明整体仅9KB结构紧凑适合快速通读和二次开发。目前已有106人学习下载。代码源自作者毕业设计经过完整运行测试答辩评价较高核心流程可直接复用也可以在此基础上修改用于其他目标检测任务。单图与视频推理示例有助于直观观察检测结果帮助理解目标检测从数据准备到模型部署的基本流程是一份轻量而实用的项目起步参考。1. 道路损坏检测用 SSDVGG16图的就是能落地道路损坏检测这种任务跟通用物体检测有个明显的差异拍回来的路面照片背景单一但损坏目标裂缝、坑槽、修补带尺寸跨度大、边缘模糊、跟路面纹理容易混淆。用两阶段检测器虽然精度上限高但部署时的推理速度和显存占用往往劝退工程落地。SSD 这种单阶段检测器在速度和精度之间取得了较好的平衡配合 VGG16 做骨干网络结构直白、权重成熟、部署链条短是我在这种垂直场景下的常见首选。PyTorch 生态下自己拼一个 SSDVGG16 的检测管线难点不在网络结构本身而在数据标注的格式转换、先验框的匹配策略、以及在只有少量道路损坏样本时怎么把迁移学习的效果榨出来。这篇就把从数据集准备、模型构建、训练调参到推理验证的完整链路讲清楚代码可以直接抄走改。这套方案的适用人群是有一定 PyTorch 基础、想用现成检测框架做垂直场景落地、手头已经有或者准备标注道路损坏数据的工程师。如果你完全没有跑过任何检测模型建议先花半天跑通 PyTorch 官方检测教程再回来。2. SSD 检测原理与 VGG16 骨干的选型理由2.1 SSD 为什么适合道路损坏这种多尺度目标SSDSingle Shot MultiBox Detector的核心设计是在多个不同分辨率的特征图上同时做分类和回归。它的出发点很直接浅层特征图分辨率高、感受野小适合检测小目标深层特征图分辨率低、感受野大适合检测大目标。所以 SSD 不搞两阶段那样的候选区域提取而是直接在每层特征图的每个格子上铺一组预设尺度和长宽比的先验框prior box一次性输出所有框的类别置信度和位置偏移。这个设计对道路损坏检测有一个现实意义路面裂缝往往细长在整张图中占比可能不到几个像素宽而坑槽或修补带又是成片的占图面积大。如果只在最后一层特征图上做检测小裂缝基本会漏掉。SSD 从 VGG16 的 conv4_3 层开始一直到额外添加的 conv7、conv8、conv9 等层共使用了 6 层不同分辨率的特征图小目标在前几层召回大目标靠后几层兜底。# SSD 六层特征图的典型配置 prior_config { feature_maps: [38, 19, 10, 5, 3, 1], # 六层特征图尺寸 min_sizes: [30, 60, 111, 162, 213, 264], # 每层先验框最小边长 max_sizes: [60, 111, 162, 213, 264, 315], # 每层先验框最大边长 aspect_ratios: [[2], [2, 3], [2, 3], [2, 3], [2], [2]], variance: [0.1, 0.2] # 位置回归的方差归一化系数 }这段配置里feature_maps对应 300×300 输入下图各层的特征图分辨率min_sizes和max_sizes决定了每层先验框的覆盖尺度范围从 30 像素到 315 像素的跨度基本覆盖了路面损坏目标从细小裂缝到大面积坑槽的尺寸分布。aspect_ratios里 2 和 3 的长宽比适合捕捉横缝、纵缝这种条状目标而 1:1 的方形框由min_sizes和max_sizes按公式自动生成。variance是位置回归时的归一化系数数值取自原论文一般不需要动。SSD 的先验框总数在 8732 个左右这意味着它是在一个相当大的搜索空间里做稠密预测。好处是几乎每个真实目标都能找到足够的正样本锚点坏处是正负样本比例严重失衡所以训练时必须用困难负样本挖掘hard negative mining把负样本里损失最大的那些挑出来参与反向传播保持正负比例大约 1:3。2.2 VGG16 作骨干的收益与局限图像检测中backbone 承担的是底层特征提取功能。VGG16 是 16 层的卷积全连接结构结构简单规整全部使用 3×3 卷积核和 2×2 最大池化。虽然它在 ImageNet 分类上的精度早被 ResNet 等超越但作为 SSD 的骨干它有两点其他结构不容易替代的优势。第一是 PyTorch 官方 torchvision 里有训练好的 VGG16 权重迁移学习时直接加载省掉了从头训练骨干的时间。在道路损坏这种相对小众的数据集上从零初始化网络基本训不动加载 ImageNet 权重是方案成立的先决条件。第二是 VGG16 的 conv4_3 层特征图输出是 38×38空间分辨率比 ResNet 同阶段的输出更细腻对小裂缝这类目标的信息保留更完整。import torchvision.models as models # 加载 ImageNet 预训练权重替换分类头 backbone models.vgg16(weightsmodels.VGG16_Weights.IMAGENET1K_V1) # VGG16 的 features 部分保留classifier 部分丢弃 features list(backbone.features)[:30] # 截断到 conv4_3 # 将最大池化 ceil_mode 设为 True避免特征图尺寸计算误差 for layer in features: if isinstance(layer, torch.nn.MaxPool2d): layer.ceil_mode Trueweightsmodels.VGG16_Weights.IMAGENET1K_V1是新版 torchvision 的推荐写法老版本里pretrainedTrue的写法现在会告警。截断在[:30]是因为 VGG16 里 conv4_3 恰好是第 30 层。ceil_modeTrue是 SSD 实现里的一个经典细节它在池化时向上取整保证某些特定输入尺寸下特征图分辨率跟先验框配置对得上否则训练时会出现尺寸不匹配的报错。VGG16 的局限也明显参数量大、计算量大且全连接层的存在让特征提取部分负担较重。在 1080Ti 级别显卡上训练 300×300 输入大约能跑到 4060ms 的推理延迟部署到边缘设备时需要额外的剪枝或 TensorRT 优化。如何对 VGG16 进行剪枝这类思路可以留到模型上线阶段再做训练阶段先用原版结构保证收敛稳定性。2.3 数据格式与标注转换是第一个坑很多人以为跑检测模型是从写网络开始的实际上数据准备阶段消耗的时间往往是训练的好几倍。道路损坏检测数据集的标注格式五花八门常见的是 PASCAL VOCXML 标注框和目标类别和 COCOJSON 标注部分自建数据可能是 LabelMe 的 JSON 或 YOLO 的 TXT 格式。SSD 的经典 PyTorch 实现吃的是 VOC 格式所以第一步通常是统一格式。import xml.etree.ElementTree as ET def voc_parse(xml_path, class_to_idx): 解析单张 VOC XML 标注 tree ET.parse(xml_path) root tree.getroot() boxes [] labels [] for obj in root.iter(object): name obj.find(name).text if name not in class_to_idx: # 跳过不在类别表里的目标 continue bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) boxes.append([xmin, ymin, xmax, ymax]) labels.append(class_to_idx[name]) return boxes, labelsclass_to_idx映射表需要自己在代码里维护比如{裂缝: 1, 坑槽: 2, 修补: 3}背景类固定为 0。这段解析只处理了最基本的字段如果 XML 里有difficult或truncated标记建议在解析时单独收集训练时可以选择剔除这类难例避免它们干扰收敛。另外一个常见做法是先用标注工具如 labelImg 或 Roboflow标注原始图片导出成 VOC 格式再用脚本统一生成训练所需的voc0712.txt文件每行是图片路径和对应 XML 路径的相对映射。这个中间文件的格式虽然不是标准但许多 PyTorch SSD 实现的数据加载器都依赖它。3. PyTorch 搭建 SSD 检测网络的完整代码与训练流程3.1 搭建一个可运行的 SSD 模型结构基于 PyTorch 实现 SSDVGG16代码组织上建议拆成四个模块backbone.pyVGG16 特征提取、prior_box.py先验框生成、ssd.py整体检测头与损失函数、train.py训练主脚本。保持模块边界清晰的好处是后续调试时能快速定位问题。import torch import torch.nn as nn from backbone import vgg16_backbone class SSD300(nn.Module): def __init__(self, num_classes): super().__init__() self.num_classes num_classes # VGG16 特征提取层 额外添加的 5 层卷积 self.backbone, self.extras vgg16_backbone() # 六层特征图对应的检测头分类 位置修正 self.loc_layers nn.ModuleList([ nn.Conv2d(512, 4 * 4, kernel_size3, padding1), # conv4_3 nn.Conv2d(1024, 6 * 4, kernel_size3, padding1), # conv7 nn.Conv2d(512, 6 * 4, kernel_size3, padding1), # conv8_2 nn.Conv2d(256, 6 * 4, kernel_size3, padding1), # conv9_2 nn.Conv2d(256, 4 * 4, kernel_size3, padding1), # conv10_2 nn.Conv2d(256, 4 * 4, kernel_size3, padding1), # conv11_2 ]) self.conf_layers nn.ModuleList([ nn.Conv2d(512, 4 * self.num_classes, kernel_size3, padding1), nn.Conv2d(1024, 6 * self.num_classes, kernel_size3, padding1), nn.Conv2d(512, 6 * self.num_classes, kernel_size3, padding1), nn.Conv2d(256, 6 * self.num_classes, kernel_size3, padding1), nn.Conv2d(256, 4 * self.num_classes, kernel_size3, padding1), nn.Conv2d(256, 4 * self.num_classes, kernel_size3, padding1), ]) def forward(self, x): # 依次经过 VGG16 和 extras收集六层特征 sources [] for layer in self.backbone: x layer(x) sources.append(x) # conv4_3 for layer in self.extras: x layer(x) if isinstance(layer, nn.ReLU) is False: sources.append(x) # 后面的 5 层特征图 # 每个特征图分别生成 loc 和 conf 预测 loc [layer(x) for layer, x in zip(self.loc_layers, sources)] conf [layer(x) for layer, x in zip(self.conf_layers, sources)] return loc, conf4 * 4中的第一个 4 是每层先验框数量比如一层配 4 个框第二个 4 是位置回归的四个值中心点 x、y 和宽度、高度偏移。6 * num_classes同理先验框数为 6 时每个框输出一份类别概率。extras的构建细节被封装在vgg16_backbone()里它负责把 VGG16 最后的fc6和fc7改造成卷积形式再逐层添加新的卷积层。3.2 先验框生成与匹配策略先验框生成并不复杂核心是把每个特征图格子的坐标映射回原图坐标系再按min_sizes、max_sizes和aspect_ratios为每个格子生成一组框。需要注意的一点是先验框的坐标全部归一化到 [0,1] 区间这样不同尺寸的输入图可以共用同一套逻辑。def generate_prior_boxes(cfg, img_size300): prior_boxes [] for idx, feat_size in enumerate(cfg[feature_maps]): # 将特征图坐标映射回原图的步长 step img_size / feat_size for i in range(feat_size): for j in range(feat_size): # 每格中心在原图的坐标归一化 cx (j 0.5) / feat_size cy (i 0.5) / feat_size # 默认方形框min_size 和 sqrt(min*max) s_min cfg[min_sizes][idx] / img_size s_max cfg[max_sizes][idx] / img_size prior_boxes.append([cx, cy, s_min, s_min]) prior_boxes.append([cx, cy, s_max, s_max]) for ratio in cfg[aspect_ratios][idx]: prior_boxes.append([cx, cy, s_min * ratio ** 0.5, s_min / ratio ** 0.5]) prior_boxes.append([cx, cy, s_min / ratio ** 0.5, s_min * ratio ** 0.5]) return torch.tensor(prior_boxes) # 形状 [8732, 4]先验框的匹配在训练时执行一次即可每个真实框跟所有先验框计算 IoUIoU 大于阈值一般取 0.5的标记为正样本同时每个真实框至少匹配一个最大 IoU 的先验框防止某些中等重叠但未被覆盖的目标丢失。匹配完成后正样本的标签是目标类别负样本的标签是背景0。一个容易被忽略的坑是框编码的格式。SSD 预测的不是直接坐标而是相对先验框的偏移量。公式是g_cx (gt_cx - prior_cx) / prior_w / variance[0]g_cw log(gt_w / prior_w)。如果代码里编码格式不统一会出现训练损失不断下降但map完全上不去的情况因为推理时解码出来的框位置是错的。3.3 损失函数与困难负样本挖掘SSD 的损失分两部分加和位置损失用 Smooth L1分类损失用交叉熵。因为先验框数量多、正负样本极不平衡负样本要经过困难挖掘后再计算损失。def ssd_loss(loc_pred, conf_pred, loc_target, conf_target, pos_mask, negatives): # 位置损失只计算正样本部分 loc_loss smooth_l1_loss( loc_pred[pos_mask], loc_target[pos_mask] ) # 分类损失正样本全部参与负样本取挖掘后的子集 conf_positive cross_entropy(conf_pred[pos_mask], conf_target[pos_mask]) conf_negative cross_entropy(conf_pred[negatives], conf_target[negatives]) conf_loss conf_positive conf_negative return loc_loss conf_loss困难负样本挖掘的具体步骤是先按背景类置信度损失对所有负样本先验框排序取损失最大的若干个使负样本总数保持为正样本数量的 3 倍。这一步有效把网络的学习重心引向那些容易误判为路面的背景区域比如沥青石子纹理、路面接缝、阴影边缘。negatives这个 mask 需要在训练循环里每轮重新计算不能缓存。3.4 训练脚本的完整骨架训练主循环除了常规的前向、反向、参数更新还需要注意学习率策略和数据增强。道路损坏图片的拍摄角度、光照差异较大不玩数据增强的话模型在验证集上的精度会明显缩水。# train.py 核心训练循环 from data_loader import get_dataloader from ssd import SSD300, ssd_loss from prior_box import generate_prior_boxes model SSD300(num_classes4) # 背景 裂缝 坑槽 修补 model.load_state_dict(torch.load(vgg16_ssd_init.pth), strictFalse) optimizer torch.optim.SGD(model.parameters(), lr1e-3, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones[120, 160], gamma0.1) for epoch in range(200): for images, targets in train_loader: images images.to(device) # [batch, 3, 300, 300] targets [t.to(device) for t in targets] # list of [num_obj, 5] loc_pred, conf_pred model(images) # targets 里编码好的先验框匹配结果 loc_loss, conf_loss ssd_loss(loc_pred, conf_pred, targets) loss loc_loss conf_loss optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() if epoch % 10 0: torch.save(model.state_dict(), fcheckpoints/ssd_epoch_{epoch}.pth)strictFalse的加载方式很关键。因为 SSD 在 VGG16 基础上额外添加了extras卷积层和检测头直接加载原始 VGG16 权重会报缺失 key。用strictFalse只把 backbone 部分初始化新加层保持随机初始化。SGD 优化器搭配weight_decay5e-4是检测任务里比较通用的配置Adam 在这个场景下容易出现早期收敛过快后期震荡的问题。训练过程中关注两个指标loc_loss是否持续下降以及conf_loss中正样本部分在整体损失中的占比。如果正样本损失占比特别小说明先验框匹配可能存在大量漏匹配需要检查 IoU 阈值和正样本挖掘策略。4. 数据集、训练参数与迁移学习的实战经验4.1 道路损坏数据集怎么组织公开的道路损坏数据集比较常用的是 RDD2020 和 CFD裂缝数据集类别定义和标注风格各不相同。使用前建议做一次类别映射把相似类别合并避免模型在小数据集上区分过细的语义。例如把「横向裂缝」和「纵向裂缝」统一成「裂缝」可以显著降低训练难度。如果你的场景是市区的固定路段巡检更稳妥的做法是用自己的图片数据做标注而不是依赖公开数据集。因为相机高度、角度、光线条件不同直接拿公开数据集训练的模型在自有数据上表现大概率不理想。标注量建议从 1000 张开始类别别超过 5 类数据增强和迁移学习能在小样本下撑起一个可用模型。# 推荐的数据集目录结构 data/ ├── VOCdevkit/ │ ├── VOC2007/ │ │ ├── JPEGImages/ # 原始图片 │ │ ├── Annotations/ # VOC XML 标注 │ │ └── ImageSets/Main/ │ │ ├── train.txt # 训练集样本列表 │ │ └── val.txt # 验证集样本列表训练集和验证集按 8:2 划分且划分时最好按路段分组避免同一路段的相似图片同时出现在训练集和验证集里否则验证结果虚高。很多人在这种场景下犯的错误是没有做时间维度的划分——同一天、同一光线条件下拍摄的照片放进两个集合模型学到的是光照模式而不是损坏特征。4.2 关键训练参数的经验值训练参数直接影响最终 map值得逐个细说。输入尺寸用 300×300 是 SSD 的标准配置速度快、显存占用小如果道路损坏目标中细小裂缝占比大可以换成 512×512 输入小目标召回率会提升但显存翻倍、训练时间也拉长。batch size : 32显存不够降到 16配合梯度累积 初始学习率 : 1e-3迁移学习场景 milestones : [120, 160] 个 epoch 处降为之前的 0.1 warmup : 前 5 个 epoch 线性升至目标学习率 mosaic 增强 : 开启对裂缝这种细长目标有遮挡鲁棒性增益 颜色抖动 : 亮度 ±20%对比度 ±20%warmup的作用是让随机初始化的检测头在训练初期用较小学习率稳定下来避免 backbone 参数被冲乱。milestones的值要结合数据集大小调整数据多时可以拉长到 [150, 200] 附近数据少时网络收敛快100 个 epoch 后降学习率更合理。4.3 训练中的常见怪现象与排查路径最典型的问题是「训练损失正常下降但验证集 mAP 始终很低」。这个现象在检测任务里一般指向三个方向先验框匹配异常、NMS 参数不匹配、或者验证阶段的数据处理与训练不一致。先检查先验框匹配。把一张训练图的真实框、匹配到的先验框、模型预测框画在一起看如果模型输出的框位置都对但尺寸偏大或偏小多半是variance和编码公式不匹配。如果框的位置完全乱掉检查loc_pred的输出顺序是否按照cx, cy, w, h排列。再检查验证阶段的处理。很多人训练时用了数据增强验证时忘了关闭color_jitter导致验证集图片颜色偏移模型输出置信度整体下降。这类问题在代码审查时比较隐蔽一般建议写个脚本对比训练和验证的数据加载器是否使用了同一套预处理逻辑。5. 推理部署优化与验证指标分析5.1 从 PyTorch 到 TensorRT 的转换与精度对齐推理优化的常见路线是先做权重转换再做算子融合。PyTorch 模型直接部署有两个问题一是前处理图片缩放、归一化和模型推理是分离的有额外 IO 开销二是 PyTorch 的 GPU 推理在小 batch 下调度开销占比高帧率上不去。TensorRT 是在 NVIDIA GPU 上做推理优化时绕不开的工具。转换流程分两步先把 PyTorch 权重导出为 ONNX 格式再用 TensorRT 解析 ONNX 并构建 engine。ONNX 导出时最容易踩的坑是 PyTorch 里的动态尺寸和nn.MaxPool2d(ceil_modeTrue)算子——TensorRT 对ceil_mode支持不完整某些版本需要手动替换成等价的 Padding 标准池化组合。import torch.onnx def export_onnx(model, output_path): model.eval() dummy_input torch.randn(1, 3, 300, 300).cuda() torch.onnx.export( model, dummy_input, output_path, input_names[input], output_names[loc, conf], dynamic_axes{input: {0: batch}}, # 允许动态 batch opset_version11 )opset_version11在 TensorRT 兼容性上比较稳妥新版 TensorRT 支持更高的 opset但如果你的部署环境是老版本驱动保持 11 是安全的。导出后用polygraphy工具跑一次精度比对观察转换前后的输出差异差异超过 1e-2 就需要用 FP32 而不是 FP16 做推理。5.2 推理侧的一个加速落地技巧检测模型推理速度的提升在批量请求场景下往往比单张图片优化更显著。假设你的道路巡检系统是一台车载工控机相机连续出图模型推理线程如果每张图都等前一张完成后再处理GPU 利用率很低。正确的做法是把多张图片拼成一个 batch 再推理。def batched_infer(model, image_queue, batch_size4): while True: batch [] while len(batch) batch_size: img image_queue.get(timeout0.1) if img is not None: batch.append(img) if not batch: continue imgs_tensor torch.stack(batch).cuda() with torch.no_grad(): loc, conf model(imgs_tensor) # 后处理解码、NMS、输出结果image_queue可以是相机采集线程和推理线程之间的缓冲队列。把 batch 从 1 调到 4 通常能带来 23 倍的吞吐提升代价是单张图片的延迟增加了 batch 排队时间。对于道路巡检这种按路段连续采集的场景吞吐优先于单张延迟这种方式明显合理。5.3 用可视化验证模型学到的到底是什么模型训练完成后验证工作不能只看 mAP 数字。把验证集里的预测结果画出来按置信度排序重点看两类错误漏检和误检。漏检往往集中在细小裂缝和不规则修补边界上这两类目标在特征图上占据的像素少特征不明显。误检则经常出现在路面接缝、车道线边缘、油渍区域。from PIL import Image, ImageDraw def visualize_detection(image_path, boxes, scores, labels, threshold0.5): img Image.open(image_path).convert(RGB) draw ImageDraw.Draw(img) for box, score, label in zip(boxes, scores, labels): if score threshold: continue draw.rectangle(box, outline(255, 0, 0), width3) draw.text((box[0], box[1]), f{label}:{score:.2f}, fill(255, 255, 0)) img.save(prediction.jpg)可视化的价值在于让你直观看到模型的失败模式而不是只看损失曲线。如果所有误检区域在图上看起来都很像裂缝边缘说明训练数据里缺少带有这种纹理的负样本那就不是调参能解决的问题需要回到数据收集环节补充。这正是检测工程跟纯算法竞赛差异最大的地方数据的边际收益往往高于网络结构的小改动。本文还有配套的精品资源点击获取
返回列表