简介:一份以路面裂缝检测系统为实战对象的计算机视觉与深度学习项目案例教程,采用MATLAB和Python作为开发工具,面向从事图像处理、道路养护及交通工程研究的工程技术人员与相关专业学生。资源为一份PDF格式的电子文档,大小约1.46兆字节,内容出自《计算机视觉与深度学习实战——以MATLAB、Python为工具》一书的第21章,目前已有726人学习或下载。教程从传统人工检测效率低、主观性强且存在安全风险等痛点切入,系统阐述裂缝线状目标检测的理论基础,包括图像灰度化的分量法、最大值法、均值法与加权平均法,以及均值滤波、中值滤波等噪声抑制手段,并指出裂缝图像因窄小、低对比度、自然间断等特点对传统算法的挑战。随后深入讲解利用卷积神经网络进行裂缝特征学习与自动识别的方法,完整覆盖数据集准备、模型选择与训练、系统测试与优化等环节,并给出基于MATLAB与Python深度学习框架的实现思路。读者可通过该案例掌握从图像预处理到模型部署的完整流程,为低对比度、复杂背景下的线状目标识别项目提供可复用的设计参考。
1. 路面裂缝检测不是图像分类,而是一条像素级分割流水线
路面裂缝检测系统,这个标题看起来像是一个通用的计算机视觉大作业,但真正动手时你会发现,它跟你平时做的猫狗识别、MNIST 手写数字完全不在一个难度层级。裂缝检测的落地本质是像素级语义分割——模型不但要判断“这张图有没有裂缝”,还要在 512×512 的图像里把每一条龟裂、横向裂缝、纵向裂缝的轮廓精确抠出来,误差超过几个像素就可能让后续的裂缝宽度计算和等级评估彻底失效。
为什么这个项目开发案例教程要同时用 MATLAB 和 Python 两套工具链?我的理解是:MATLAB 适合做图像预处理、形态学后处理和快速原型验证,Image Processing Toolbox 里一堆现成函数能把连通域分析、骨架提取这类活儿压到几行代码;Python 则胜在深度学习生态完整,PyTorch 训练 U-Net 的效率比 MATLAB 的 Deep Learning Toolbox 顺畅不少。如果是课程设计或者研究生阶段的项目入门,用 Python 走通训练流程、再用 MATLAB 做结果分析和报告出图,是一条性价比最高的路线。这篇笔记就是沿着这条双工具链路线,把路面裂缝检测从数据准备到模型部署的完整链路拆开讲清楚,包括参数取值、运行命令和踩过的坑。
2. 裂缝数据集决定模型上限:标注规范、样本均衡和切图策略
2.1 公共数据集与自采数据怎么选
路面裂缝检测的公开数据集不少,常见的有 CFD(CrackForest Dataset)、Crack500、DeepCrack,另外还有一个比较经典的是 GAPs 数据集。这些数据集各有各的脾气:CFD 分辨率低但有像素级标注,Crack500 是从 500 张 2000×1500 左右的原始路面图里切出来的,训练样本量大但标注质量参差不齐。实际做系统设计的时候,我一般建议别直接拿某一个数据集一口气训到底,先把几个数据集的标注格式统一成 8 位单通道 PNG,0 是背景、1 是裂缝,再做一个灰度分布直方图对比,看看标注的裂缝占整张图面积的比例大概是多少。裂缝这种目标在整张图里通常只占 1% 到 5% 的像素,神经网络如果直接在这种极度不平衡的数据上训练,loss 会被背景类完全绑架,最后预测结果全黑,一条裂缝都出不来。
如果项目要求用自采数据,手机拍摄的路面图要先做透视校正和光照归一化。我踩过的坑是用手机在中午顶光下拍的路面,裂缝阴影偏重而且沥青纹理的高光区域局部过曝,直接喂进网络训练,Pretrained 权重反而成了干扰。自采数据建议在三个时间段采集——清晨低角度光、阴天漫射光、傍晚斜射光,用三组覆盖同一路段,这样模型对光照变化的鲁棒性会好不少。所有原始图片统一压到 512×512 或 448×448 再做训练,不要图省事用原图全分辨率,显存会先爆掉。
2.2 标注格式转换:从 JSON 到 PNG 掩码的一个小脚本
很多开源裂缝数据集用的是 JSON 多边形标注,而 U-Net 训练要的是掩码图。转换这一步有个常见做法是 OpenCV 的fillPoly把多边形光栅化成 0/255 的单通道图,但注意原始 JSON 里的坐标如果是相对坐标(比如取值在 0~1 之间的归一化坐标),必须乘以图像宽高再取整,否则多边形画出来全部偏移到左上角。
import json import numpy as np import cv2 def json2mask(json_path, img_width, img_height, mask_path): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) mask = np.zeros((img_height, img_width), dtype=np.uint8) # 一张图里可能有多个裂缝区域,全部用多边形填充 for shape in data['shapes']: points = np.array(shape['points'], dtype=np.float32) # 归一化坐标还原到像素坐标,注意先乘后取整 points[:, 0] *= img_width points[:, 1] *= img_height points = points.astype(np.int32) cv2.fillPoly(mask, [points], color=255) cv2.imwrite(mask_path, mask) print(f'saved mask: {mask_path}, unique values={np.unique(mask)}')这段脚本有三个地方值得注意。第一,shapes字段是 LabelMe 标注工具的常用输出格式,如果你用的标注工具是其他家的,字段名要对应改;第二,归一化坐标转换时不能直接astype(np.int32)之后再乘宽高,那样精度丢失很严重,二值掩码锯齿会非常明显;第三,最终写出的掩码里只有 0 和 255 两种值,训练代码里通常要再mask / 255转成 0 和 1。
2.3 正负样本失衡的两个补救手段
路面裂缝特有的样本失衡可以拆成两个层面。第一层是像素级失衡,裂缝像素占比太少,这个用 Dice Loss 或者 Weighted Cross Entropy 解决,后面第四章会展开讲函数怎么设计。第二层是区域级失衡,有些 512×512 的切图里整张图都是干净沥青,一条裂缝都没有,这种纯背景图在训练集里如果占比太高,模型会倾向于输出全背景。
我的处理方式是控制纯背景图比例不超过总训练集的 15%,多余的背景图从训练列表里踢掉。另一个补救手段是负难例挖掘:第一轮模型训练完之后,用模型去预测训练集的一部分,挑出预测错误的上图——也就是存在裂缝但模型漏检的图——把它们单独放到一个文件夹里做第二轮 Fine-tune。这个操作能比较明显地提升裂缝较细或者对比度较低那部分样本的召回率,值得作为系统流程写进课设报告里。
2.4 切图策略:滑动窗口切图与重叠率取值
原始路面图尺寸通常超过 1000×1000,不能直接整张塞进网络。滑动窗口切图是 U-Net 类模型做遥感或路面检测的标配方案。窗口大小我取 512×512,步长 stride 取 256,也就是 50% 重叠。这样做的好处是裂缝在窗口边缘被切断的概率会降低,训练样本量也能翻倍。预测阶段不用重叠,直接非重叠切图即可,这是训练和推理的一个常见差别。
提示:切图不要用 OpenCV 的crop循环加imwrite直接存盘,速度慢而且会写出一堆磁盘碎片。更推荐做法是在__getitem__里实时切,配合 PyTorch 的 DataLoader 多进程读取,训练时 IO 吞吐明显快很多。
3. 用 U-Net 搭裂缝分割模型:Python 训练和 MATLAB 验证的两种打开方式
3.1 为什么选 U-Net 而不是 VGG16 或者 ResNet
裂缝检测有人尝试过用 VGG16 做分类,先判断有缝没缝,再对“有缝”的图做二次处理。这个方案的问题在于裂缝宽度只有几个像素,经过 VGG16 的池化层之后,细节全没了。U-Net 用编码器-解码器结构,编码器下采样提取语义特征,解码器上采样恢复空间分辨率,中间通过 skip connection 把每一层的细节特征直接拼到解码器对应层,所以输出层的裂缝轮廓能保留到接近原图分辨率。路面裂缝又细又长又连续,这种拓扑结构恰好是 U-Net 这类带跳跃连接模型擅长处理的。
对于裂缝检测场景,U-Net 的另一个优点是数据需求量相对友好。用 ImageNet 预训练的 ResNet 做 Encoder 确实能加快收敛,但裂缝是纹理类目标,跟 ImageNet 里的物体语义差异很大,预训练权重帮助有限。我做过的对比里,从零训练 U-Net 和用 ResNet34 编码器做迁移学习的最终 mIoU 差距不到 2%,但训练时间差了接近一倍。所以课程设计级别的时间预算下,直接训练 U-Net 是性价比最高的选择。
3.2 Python 端最小 U-Net 实现:从数据加载到训练主循环
import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader import cv2 import numpy as np import os class CrackDataset(Dataset): """读取 img_dir 下的原始图和 mask_dir 下的二值掩码""" def __init__(self, img_dir, mask_dir, size=512): self.img_dir = img_dir self.mask_dir = mask_dir self.names = sorted(os.listdir(img_dir)) self.size = size def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] img = cv2.imread(os.path.join(self.img_dir, name)) mask = cv2.imread(os.path.join(self.mask_dir, name.replace('.jpg', '.png')), cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (self.size, self.size)) mask = cv2.resize(mask, (self.size, self.size), interpolation=cv2.INTER_NEAREST) img = img.astype(np.float32) / 127.5 - 1.0 mask = mask.astype(np.float32) / 255.0 img = torch.from_numpy(img).permute(2, 0, 1) mask = torch.from_numpy(mask).unsqueeze(0) return img, mask class ConvBlock(nn.Module): """两个卷积 + BN + ReLU 的标准块""" def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): """轻量 U-Net,通道数按 [32, 64, 128, 256] 递增""" def __init__(self, in_ch=3, out_ch=1): super().__init__() self.enc1 = ConvBlock(in_ch, 32) self.enc2 = ConvBlock(32, 64) self.enc3 = ConvBlock(64, 128) self.pool = nn.MaxPool2d(2) self.bottleneck = ConvBlock(128, 256) self.up3 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec3 = ConvBlock(256, 128) self.up2 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec2 = ConvBlock(128, 64) self.up1 = nn.ConvTranspose2d(64, 32, 2, stride=2) self.dec1 = ConvBlock(64, 32) self.out = nn.Conv2d(32, out_ch, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) b = self.bottleneck(self.pool(e3)) d3 = self.dec3(torch.cat([self.up3(b), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.out(d1)这个实现里有两个参数值得注意。第一是通道数从 32 起步而不是经典的 64,内存占用降低一半,在 6GB 显存的卡上也能带起来,而精度损失不大。第二是torch.cat拼接 skip connection 时放在up上采样之后、dec卷积之前,顺序不能反,这是官方 U-Net 的原始设计。mask在读取时用cv2.INTER_NEAREST做 resize,不能用线性插值,否则掩码边缘会出现灰阶过渡值,训练时会让 loss 原地抖。
3.3 训练脚本的 Loss 函数与评价指标
裂缝分割的 Loss 不建议用纯 Cross Entropy,正负样本差距太大。常见做法是 Dice Loss 与二分类交叉熵的加权组合。Dice Loss 的公式是 1 减去两倍交集除以并集,它的梯度天然地放大“裂缝区域没预测对”那一部分的权重,在多轮训练中能稳住稀疏目标的优化方向。
def dice_loss(inputs, targets, smooth=1.0): inputs = torch.sigmoid(inputs) inputs = inputs.contiguous().view(inputs.size(0), -1) targets = targets.contiguous().view(targets.size(0), -1) intersection = (inputs * targets).sum(dim=1) dice = (2.0 * intersection + smooth) / (inputs.sum(dim=1) + targets.sum(dim=1) + smooth) return 1.0 - dice.mean() class CombinedLoss(nn.Module): """BCE 加 Dice 的混合损失,适合裂缝这种极稀疏目标""" def __init__(self, weight_dice=0.7): super().__init__() self.bce = nn.BCEWithLogitsLoss() self.weight_dice = weight_dice def forward(self, pred, target): return self.bce(pred, target) * (1 - self.weight_dice) + dice_loss(pred, target) * self.weight_dice为什么 Dice 的权重取 0.7?试过 0.5 和 0.9,0.5 时背景类 loss 占比高,训练初段模型输出整体偏暗;0.9 时早期训练震荡厉害,Dice 的梯度在目标极稀疏时会出现不稳定的跳变。0.7 是收敛曲线相对平滑的取值。另外这个inputs.sum用的是遍历所有像素,不是只求和目标像素,写的时候注意别混。
3.4 MATLAB 侧验证:加载训练好的权重做后处理
模型训练完,论文里通常需要跑一组实验结果对比图。MATLAB 在这块的体验比 Python 好很多——读取 PyTorch 导出的 ONNX 模型做前向推理,然后接形态学后处理和可视化,代码量很短:
% 加载 PyTorch 导出的 ONNX 格式裂缝分割模型 net = importNetworkFromONNX('crack_unet.onnx'); % 读取测试图并缩放到 512x512,数值范围转为 [-1, 1] img = imresize(imread('test_crack.jpg'), [512, 512]); img = im2single(img) * 2 - 1; % 前向推理:输出是 1 通道的 logits,sigmoid 转成概率 prob = predict(net, img, 'Outputs', 'output'); prob = 1 ./ (1 + exp(-prob)); % 简单阈值分割 + 开运算去孤立噪声点 + 骨架提取 bw = prob > 0.5; bw = imopen(bw, strel('disk', 2)); skel = bwskel(bw); % 叠加在原图上并设置灰度亮度和透明度 figure; imshow(img); hold on; vis = imshow(repmat(uint8(bw) * 255, [1 1 3])); set(vis, 'AlphaData', bw * 0.6);注意 MATLAB 的predict对 ONNX 输出的维度顺序可能跟 PyTorch 不一致,中间可能需要一次permute。另外bwskel得到的骨架线可以直接喂给后续的裂缝宽度统计模块,这是 MATLAB 相比 Python 的工程便利之处。
4. 训练配置和参数调优:学习率、Batch Size、数据增强的定量取值
4.1 学习率与 Batch Size 的联动取值
裂缝分割在这个模型配置下的训练参数量大约在 780 万到 900 万之间(通道数 32 起步的轻量 U-Net),不算是大模型,但学习率选不好照样会loss 震荡。初始学习率1e-3配合 ReduceLROnPlateau 是我做这类项目常用的配置,具体触发逻辑是连续 5 个 epoch 验证集 Dice 没有上升,学习率乘以 0.5。
Batch Size 的设置要结合显存来算。512×512 输入、通道 32,单卡 6GB 显存时 Batch Size 取 8 是安全值,取 16 大概率 OOM。如果你用的是 8GB 以上的显卡,Batch Size 可以提到 12 到 16。注意 Batch Size 翻倍的同时学习率也要跟着调大一些,常见做法是线性缩放,比如 Batch Size 从 8 翻到 16,学习率从1e-3提到1.4e-3,这样收敛速度不会因为步数减少而降太多。
4.2 数据增强的顺序和参数
裂缝检测的数据增强有两个原则:不能改变裂缝的拓扑形态,不能把裂缝“抹掉”。旋转、翻转、缩放这类几何增强直接用;亮度、对比度调整要注意幅度,Gamma 取值在 0.8 到 1.2 之间,太亮或太暗会让本来就细的裂缝对比度进一步恶化。
import imgaug.augmenters as iaa # 按概率组合增强算子,顺序固定:几何变换 -> 颜色扰动 -> 高斯噪声 aug = iaa.Sequential([ iaa.Sometimes(0.7, iaa.Affine(scale=(0.9, 1.1), rotate=(-30, 30), shear=(-8, 8))), iaa.Sometimes(0.5, iaa.LinearContrast((0.9, 1.15))), iaa.Sometimes(0.3, iaa.AdditiveGaussianNoise(scale=(0, 0.03 * 255))), ], random_order=False)这套增强里有一个隐藏参数容易踩雷:rotate设为 ±30 度以上时,图像四角会出现黑色填充区域,这些区域不是路面纹理,模型会学会“角落里黑块不是裂缝”,测试时如果真实图上恰好有阴影,就会漏检。解决方法是同时开iaa.Affine的cval参数,把填充值设为路面纹理的平均灰度而不是 0。
4.3 训练轮数和早停策略
从零训练 U-Net 在这个数据集规模(假设 2000 张 512×512 切图)下,60 到 80 个 epoch 能收敛。判断收敛不能只看训练 loss,我用验证集 Dice 做早停监控,保存验证集上表现最好的权重,不保存最后一次迭代的结果。训练完毕后对照训练集 Dice 和验证集 Dice,如果两者差距超过 8%,要检查是不是数据增强过强引入太多样本噪声,或者训练集和测试集分布差异太大。
5. 训练和推理中的五个常见绊脚石:现象、原因和处置
5.1 模型预测全黑,一条裂缝都检测不到
现象:验证集 Dice 到 0,预测结果全部是背景。原因:这个坑九成出在标签预处理。掩码图在Dataset.__getitem__里被mask.astype(np.float32) / 255.0转成了 0 到 1,但如果原始掩码不是 0 和 255 而是 0 和 1,除以 255 之后裂缝像素值变成 0.004,几乎等于背景,梯度直接消失。解决:在数据准备脚本里做一次np.unique(mask)检查,确保掩码只有两个值;训练代码里不除以 255,直接读原始值。
5.2 训练 loss 下降,但预测结果碎成雪花状
现象:验证 loss 曲线正常下降,但预测的裂缝图里散布大量孤立像素噪声点,裂缝形态不连续。原因:模型把沥青路面上的细纹理当成了裂缝。这类误检大多来自训练集里对比度高的路面纹理区域没有被标注为负样本,模型学到了错误的纹理特征。解决:检查训练集图像,凡是路面本身有清晰纹理的图,要么在标注时把非裂缝纹理单独建一个类别,要么用 Mask R-CNN 这种基于区域的模型替代 U-Net 做实例级过滤。另一个偏工程的手段是在后处理时用bwareaopen删除小于 500 像素的连通域,配合imclose连接断裂的裂缝段。
5.3 裂缝细线在中途断裂,宽度不连续
现象:预测的裂缝线中间出现断点,一条 200 像素长的裂缝被切成了三四段。原因:模型对裂缝与背景交界处的置信度不够高。裂缝在图像里常常只有 2 到 5 个像素宽,经过编码器的两次下采样后,特征图的空间分辨率只剩 128×128,裂缝的细长结构信息严重丢失,上采样恢复出来就断了。解决:优先加宽输入分辨率到 768×768,代价是显存占用上升;其次把编码器中 MaxPool 改成 Stride 卷积,整体池化次数减一次;最省事的方法是后处理里做一个形态学闭运算,imclose(bw, strel('line', 5, angle)),角度参数可以根据裂缝的主方向设定。
5.4 验证集随机切图导致同一裂缝重复统计
现象:验证集 Dice 明显虚高,换了另一组测试图后性能大幅下滑。原因:滑动窗口切图时验证集与训练集的切图窗口重叠,同一裂缝可能既出现在训练窗口又出现在验证窗口,导致数据泄漏。解决:切图之前先按整图分配,保证同一张原始路面图的所有切块只出现在训练集或只出现在验证集中,不在切图后做随机划分。这是工程上避免数据泄漏的最常用做法。
5.5 用 MATLAB 输出报告时图像尺寸对不上
现象:在 MATLAB 里把预测掩码叠加到原图上,发现裂缝位置偏移了几个像素。原因:imresize默认用双三次插值,将掩码缩放到原始尺寸时边界被平滑,裂缝边缘偏移半像素以上。解决:MATLAB 侧对掩码做 resize 时指定最近邻插值:mask = imresize(mask, [H, W], 'nearest');如果要做亚像素级别的宽度测量,就不应该在原图上直接测量预测掩码,而应该把模型输出的概率图上采样到更高分辨率再测量。
6. 验证方法论:除了 Dice 之外,用裂缝连续性指标判断模型可不可用
很多课设和论文止步于展示几个 Dice 分数和几张预测图,但真实的路面裂缝检测系统要评判能不能落地,Dice 远远不够。裂缝检测的最终用户关心的是两件事:有没有漏掉一条宽度超过阈值的真实裂缝、以及检测出的裂缝位置能不能支撑后续修复决策。这两个诉求对应的指标是每张图的裂缝级召回率和预测骨架线的端到端连通度。
我做验收时有一个固定流程:先对验证集每张图计算整图级召回率——如果模型在 512×512 的切图里漏掉了超过 30% 的标注裂缝片段,不管 Dice 多高都判定不合格;然后用bwskel提取真实标注和预测结果的骨架,把骨架分成若干个 20 像素的段,统计预测骨架与真实骨架在 3 像素范围内匹配上的段的比例。这个比例低于 0.6 时,说明预测结果虽然像素覆盖率还行,但裂缝是断断续续的,无法支撑宽度测量。
关于部署参数还有一个容易被忽视的细节:阈值的选择不能固定 0.5。训练结束后,我会在验证集上扫描 0.3 到 0.7 的阈值,画出 Precision-Recall 曲线,选 PR 曲线拐点的阈值作为推理默认值。对裂缝检测这个场景,漏检的代价是裂缝从 1 毫米发展到 3 毫米才被发现,所以我通常偏向低阈值,允许小幅误报,靠后处理的面积过滤把噪声去掉。如果后续要跑 FPGA 或者用 TensorRT 做加速,ONNX 导出的模型里需要把后处理逻辑剥离干净,只在模型里保留卷积输出,阈值和连通域过滤放到加速框架的外部算子去做。
这套双工具链方案从批处理脚本到验证指标,大致覆盖了路面裂缝检测从零到交付的整个路径——Python 负责训练和调参,MATLAB 负责后处理和报告可视化,中间用 ONNX 作为模型交换格式。作为收尾提醒一句:裂缝检测模型的“能跑”跟“能用”之间差一个完整的验证闭环,我的习惯是每次换数据集都重新算一遍阈值和骨架连通度,不沿用旧参数,这个习惯替我挡过不少次测试翻车。希望这套流程对你做路面裂缝检测系统的课程设计或工程落地有参考价值。
本文还有配套的精品资源,点击获取