道路裂纹分割这两年在地面基础设施巡检、道路病害评估这类方向里越来越常见,很多做视觉算法的朋友都会遇到类似需求。手头正好有一份700张的道路裂纹语义分割数据集,像素标注是0和1,标准的VOC格式,mask、json、txt三种格式都齐了。这篇就从这个数据集出发,把我实际使用中的经验、踩过的坑、以及一套完整可复现的实操流程整理出来,给准备做同类任务的同学一个参考。无论你是刚接触语义分割的新手,还是想快速验证裂纹检测方案的老手,这篇文章都能帮你少走不少弯路。
1. 项目定位与数据集设计思路拆解
1.1 道路裂纹分割的核心应用场景
道路裂纹检测本质上是计算机视觉里的一个细分方向,属于语义分割任务的典型应用。传统方式靠人工巡检,养护人员用肉眼或者简单的测量工具去判断路面有没有裂缝、裂缝有多长多宽,效率低不说,主观性还强——不同的人看同一条裂缝,结论可能都不一样。
机器学习介入之后,主流做法是先采集路面图像,然后对图像里的每个像素做分类:是裂纹还是正常路面。这就是语义分割。相比单纯的目标检测(给裂纹画个框),语义分割能精确到像素级轮廓,后续可以做裂纹长度计算、宽度测量、破损面积统计,这些指标对于路面损坏评定等级非常关键。
700张图的规模在这个领域属于中小型数据集。它适合做算法验证、模型选型测试、以及教学演示。如果你的目标场景是城市级道路普查,这个量级肯定不够,需要结合数据增强和预训练模型来弥补;但如果只是做一个技术预研,验证U-Net这类模型在你的数据上能不能work,700张是完全够用的起点。
1.2 为什么选择二分类语义分割
标注像素是0和1,意味着这是一个纯二分类问题:0代表正常路面背景,1代表裂纹区域。这种设计有几个明显好处:
第一,标注成本低。做像素级标注本身就是最贵的环节,如果分成多类(比如横向裂缝、纵向裂缝、网状裂缝、坑槽),每类都要单独标注,工作量翻倍,而且类别之间边界模糊,标注一致性很难保证。二分类只需要标注"是不是裂纹",标注员的标准容易统一。
第二,模型更容易收敛。类别数越少,模型需要学习的特征边界越简单。裂纹和正常路面在纹理、颜色、亮度上本身就有明显差异,二分类模型用很小的参数量就能得到不错的效果,训练时间也短。
第三,后续扩展方便。二分类结果可以当做一个裂纹概率图来用,想做多分类时,在这个基础上继续细分就行。很多工程落地方案都是先用二分类模型做裂纹检出,再用另一个分类器判断裂纹类型,这种两步走的架构在工业界非常常见。
当然,二分类也有局限。如果路面上同时存在多种病害,或者裂纹形态差异极大,单一二分类模型可能会漏检某些特殊类型。这个在第三节的模型调优部分会详细展开。
1.3 为什么选用VOC格式
VOC格式是从Pascal VOC数据集衍生出来的标准格式,最早用于目标检测和分割任务。它的核心组织方式是一个图像对应一个同名文件:原图放在JPEGImages目录,标注放在SegmentationClass目录,文件名一致,通过文件名来关联。
现实中很多人习惯用COCO格式(JSON文件统一管理所有标注),或者直接用PNG mask文件。我对比过几种格式的差异,VOC格式在语义分割任务里其实有几个很实际的优势:
一是直观。每一张图对应一张同名mask图,调试的时候打开文件夹就能对应上,不需要像COCO那样去解析JSON数组、查annotation id对应关系。对于700张这种中小规模数据集,文件夹方式的直观性远大于JSON集中管理。
二是兼容性好。几乎所有主流分割框架(无论是自己写PyTorch DataLoader,还是用现成的mmsegmentation、paddle seg套件),都原生支持VOC格式的数据组织方式。训练、验证、测试三个阶段,只需要维护一个txt文件列出图片名列表就行,非常灵活。
三是转格式容易。VOC转COCO、转Cityscapes格式都很方便,网上有大量成熟脚本。反过来COCO转VOC反而要费点功夫。如果拿不准项目后续会用什么框架,先选VOC格式基本不会走错路。
2. 数据集结构与格式细节解析
2.1 目录结构与文件组织
拿到这份数据集之后,第一件事就是把目录结构理清楚。标准的VOC分割数据集长这样:
VOCdevkit/ └── VOC2007/ ├── JPEGImages/ # 原始路面图像 ├── SegmentationClass/ # 像素级标注mask(PNG格式) ├── Annotations/ # 可选,目标检测用XML标注 ├── ImageSets/ │ └── Segmentation/ │ ├── train.txt │ ├── val.txt │ └── trainval.txt └── labels.txt # 类别标签定义这份700张的数据集对应的是这个结构。实际使用中JPEGImages里放的是路面拍摄原图,分辨率以拍摄设备为准;SegmentationClass里放的是标注好的PNG文件,每个像素的取值只有0和1。
有个细节需要特别注意:VOC标准格式里,分割标注的类别索引是从背景0开始的。也就是说0是背景,1是第一个前景类别(这里是裂纹)。有些数据集的mask图会把0和255作为两类像素值,那种是另一种约定,加载的时候要做归一化处理。这份数据集的0/1约定更简洁,直接用就行。
2.2 mask、json、txt三种格式的差异与用途
拿到手里有mask、json、txt三种格式,很多人会困惑到底用哪个。我逐个说一下实际用途:
mask格式(PNG文件)是真正的训练素材。模型训练时读取的就是这个文件,每个像素值对应类别索引。PNG是无损压缩格式,不会像JPEG那样产生压缩失真,边缘像素的标注值不会被篡改,这是语义分割对标注文件最基本的要求。千万不能把mask存成JPG,压缩产生的伪影会让边缘类别错乱,模型训练出来效果会打折扣。
json格式一般有两种形态。一种是标注工具导出时的原始标注数据,比如用LabelMe这类工具标注时,每个多边形都对应一组顶点坐标,json里存的是这些坐标信息。另一种是COCO格式的annotation文件。如果是前者,json存在的意义是可编辑——想重新调整某个区域的标注,或者做格式转换,都需要回到原始多边形数据,不能直接在mask上改。如果是后者,则主要用于兼容使用COCO格式的框架。拿到数据时,建议先用脚本解析json,确认理解它的数据结构,别等到训练报错了才去查。
txt格式通常是训练验证集的划分列表。比如train.txt里每行是一个图片文件名(不含扩展名),val.txt是验证集列表。这个文件决定了哪些图用来训练、哪些图用来验证,直接影响最终评估指标的可靠性。在第三节我会说怎么合理划分。
三种格式的关系可以总结为:json是标注的源数据,mask是训练用的直接输入,txt是数据集的划分调度文件。
2.3 像素标注规范与数据质量检查
刚拿到数据集时,建议先做一个全面的数据体检,这一步很多人会跳过,结果训练到一半才发现问题。我整理了一套快速检查流程,照着做基本能排查掉90%的数据问题:
检查mask文件的像素值范围。正常情况应该只有0和1两个值。用Python读一张mask,用np.unique()看一眼,如果出现其他值,说明标注过程有杂质,需要清洗。
检查原图和mask的尺寸是否一致。VOC格式严格要求两者分辨率相同,如果原图是1024x768,mask成了512x384,加载的时候会错位。
检查mask的类别比例。统计一下每张图裂纹区域占总像素的比例,如果大部分图片裂纹占比不到1%,这个数据集的训练难度会比较大,需要在损失函数和增强策略上做针对性设计。
检查文件名是否一一对应。JPEGImages里有的文件在SegmentationClass里找不到同名文件,或者反过来,都属于文件缺失,训练时会莫名报错。
提示:建议写一个脚本统一检查这三个维度,每次拿到新数据集都先跑一遍,能节省大量调试时间。我就是当初跳过这一步,后来发现有几张mask和原图尺寸不一致,排查了半天。
3. 基于该数据集的实操流程
3.1 数据加载与解析
数据加载是整个流程的第一个环节。基于VOC格式,我会在PyTorch环境下配合albumentations库来做数据管线。albumentations是图像增强库,特点是速度快、支持mask同步变换,对于分割任务特别顺手。
核心加载逻辑是:读取原图和mask文件,把大小重置到统一尺寸,然后做归一化和必要的变换。下面这段代码是数据加载的核心逻辑,可以直接参考:
import os import cv2 import numpy as np import torch from torch.utils.data import Dataset import albumentations as A class VocCrackDataset(Dataset): def __init__(self, img_dir, mask_dir, file_list, image_size=(512, 512), transform=None): self.img_dir = img_dir self.mask_dir = mask_dir self.image_size = image_size self.transform = transform with open(file_list, 'r') as f: self.ids = [line.strip() for line in f.readlines() if line.strip()] def __len__(self): return len(self.ids) def __getitem__(self, idx): img_id = self.ids[idx] img_path = os.path.join(self.img_dir, img_id + '.jpg') mask_path = os.path.join(self.mask_dir, img_id + '.png') image = cv2.imread(img_path) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # 读取mask时用IMREAD_GRAYSCALE,保持0/1像素值 mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 确保mask是二值(某些工具导出会把1变成255) mask = (mask > 0).astype(np.uint8) if self.transform: transformed = self.transform(image=image, mask=mask) image = transformed['image'] mask = transformed['mask'] # 转tensor,并增加channel维度 image = torch.from_numpy(image.transpose(2, 0, 1)).float() / 255.0 mask = torch.from_numpy(mask).long() return image, mask几个关键细节需要强调:
mask读取必须用灰度模式(IMREAD_GRAYSCALE),不要用彩色模式。如果标注工具把类别1渲染成红色或其他颜色,彩色模式下读进去的通道值会混乱。
代码里做了mask > 0的归一化操作。这是为了兼容那些把前景像素标成255的mask文件。虽然这份数据集标的是1,但这个操作保留着没坏处,换成其他数据集也能直接跑。
image_size统一到512x512是权衡后的选择。裂纹本身是细长结构,尺寸太小会丢失细节;尺寸太大训练显存占用高、速度慢。对于700张的数据集,512是一个比较务实的平衡点。
3.2 数据增强策略
语义分割最怕的是过拟合,700张图如果不做增强,训练集损失很快就降到很低,验证集指标怎么也上不去。数据增强要遵循一个原则:增强方式必须模拟真实采集场景的变化,不能引入虚假的模式。
我建议按照下面的增强组合来做:
| 增强类型 | 具体操作 | 适用性说明 |
|---|---|---|
| 几何变换 | 水平翻转、旋转(±15度)、缩放 | 模拟拍摄角度变化,旋转对裂纹这种各向异性目标效果明显 |
| 颜色扰动 | 亮度、对比度、饱和度调整 | 模拟光照变化,路面在不同时间段光线差异大 |
| 噪声注入 | 高斯噪声、模糊 | 模拟雨天、灰尘遮挡等真实场景 |
| 弹性变换 | ElasticTransform | 模拟路面变形,但幅度不能太大,否则裂纹形态失真 |
用albumentations实现非常方便:
import albumentations as A train_transform = A.Compose([ A.Resize(512, 512), A.HorizontalFlip(p=0.5), A.Rotate(limit=15, p=0.5, border_mode=cv2.BORDER_CONSTANT), A.RandomBrightnessContrast(p=0.3), A.GaussianBlur(blur_limit=(3, 5), p=0.2), A.GaussNoise(var_limit=(5, 20), p=0.2), ]) val_transform = A.Compose([ A.Resize(512, 512), ])关于是否使用Cutout或者MixUp这类增强,我的经验是:对于裂纹这种需要精细边缘的任务,Cutout有概率把真正的裂纹区域挖掉,导致标签错误。除非你的裂纹形态特别粗壮,否则不建议做这种强增强。随机旋转时要注意填充模式,用BORDER_CONSTANT避免边缘产生虚假的纹理干扰。
还有一个重要的点是验证集不要做增强,只做Resize,否则验证指标会被增强操作污染,跟真实模型能力不符。
3.3 模型选型与训练配置
道路裂纹分割是一个典型的医学图像分割同构任务,U-Net和DeepLab系列是首选。U-Net结构简单、训练稳定、对少量数据友好,700张图用U-Net是最稳妥的选择。DeepLabV3+用ASPP模块扩大感受野,对细长目标的全局上下文建模更好,但训练时间更长,显存占用更大。
如果你是第一次跑这个数据,可以从U-Net起步。以ResNet34作为编码器是一种很常见的选择,预训练权重从ImageNet迁移而来,对小数据集收敛帮助巨大。模型定义可以直接用segmentation_models_pytorch这个库,省去手写网络的调试时间:
import segmentation_models_pytorch as smp model = smp.Unet( encoder_name='resnet34', encoder_weights='imagenet', in_channels=3, classes=1, # 二分类 activation='sigmoid', )训练配置方面,有几个关键参数值得说道说道。
损失函数建议用BCEWithLogitsLoss和DiceLoss的组合。裂纹任务的正负样本比例极其悬殊,单纯的交叉熵会让模型倾向于把一切都预测成背景。DiceLoss直接优化区域重叠程度,对正样本的敏感性更强。两者加权相加,我常用0.5的权重系数。
优化器选AdamW,初始学习率3e-4,配合CosineAnnealingLR余弦退火调度器。batch size视显存而定,12GB显存可以设8,显存小就设4,同时配合梯度累积。
训练轮数建议设置在60到80个epoch之间。700张图按8:1:1划分,训练集560张,每轮迭代70次,80轮也就5600次迭代,对于U-Net这种结构很快就跑完。关键是每个epoch结束都用验证集做评估,存下最优模型,不要只看最后一个epoch。
3.4 评估指标与结果解读
分割任务不能只看loss曲线,真正能反映模型好坏的是mIoU(平均交并比)和Dice系数。很多人训练完只看accuracy,这是一个严重的误区——当背景占99%的时候,把全部像素预测为背景就能拿到99%的accuracy,但这显然是无用模型。
mIoU的计算方式是对每个类别分别计算预测与真实标注的交集除以并集,再取平均。对二分类来说,更关键的是看在裂纹这一类上的IoU,通常记为crack IoU。如果一个模型的整体mIoU是0.75,但crack IoU只有0.3,说明模型基本没学到裂纹的特征。
评估代码可以参考:
import numpy as np def compute_iou(pred, target, num_classes=2): ious = [] for cls in range(num_classes): pred_mask = (pred == cls) target_mask = (target == cls) intersection = np.logical_and(pred_mask, target_mask).sum() union = np.logical_or(pred_mask, target_mask).sum() if union == 0: ious.append(float('nan')) else: ious.append(intersection / union) return ious我跑完一个完整实验后,比较关注的几个指标值和阈值经验是:crack IoU在0.55以上说明模型基本可用,0.65以上算效果不错,能稳定超过0.7属于比较少见的优秀水平。这个经验值跟数据集的裂纹粗细、图像分辨率高度相关,仅供参考,但方向是对的。
另外要注意,评估的时候需要先把网络输出的概率图取阈值转为二值结果(sigmoid输出大于0.5则为1),再做IoU计算。这个阈值不一定要固定在0.5,可以扫一遍选择F1分数最高的阈值,对细小平面的裂纹检测能有一定提升。
4. 常见问题与排查技巧实录
4.1 正负样本严重不平衡
这是裂纹分割里最典型的问题。一张512x512的图有26万个像素,裂纹区域可能只有几百到几千个像素,占比经常不到2%。训练初期,模型发现只要输出全零就能把损失压得很低,于是陷入"什么都预测为背景"的局部最优。
解决方案有几个层级。先用DiceLoss替换纯交叉熵,这是一个大的优化。DiceLoss的梯度公式对正负样本的权重大体均衡,能有效缓解类别不平衡。如果还不够,给交叉熵加上类别权重,背景权重1.0,裂纹权重5.0或更高,让模型更重视裂纹预测错误。还有一种思路是使用Focal Loss,它可以降低易分样本的损失贡献,把训练重心放在那些难以分辨的裂纹像素上。
我实际对比过,DiceLoss + 加权的组合在这个数据集上效果最稳定。Focal Loss调参麻烦,gamma和alpha的组合需要多次实验,数据量不够大时提升有限。
4.2 裂纹细小导致预测不连续
细裂纹在图像里只有1到2个像素宽,经过下采样之后,特征图上的裂纹信息可能直接被池化层吃掉。预测结果常见的问题是:裂纹中间断断续续,出现很多空洞,或者边缘被过度膨胀。
解决这个问题的核心思路是保留高分辨率信息。可以检查网络编码器最后一层输出的特征图分辨率,对于512x512输入,U-Net最深层特征图是16x16,回跳连接能补回一些细节,但仍然不够。一个有效的调整是使用深监督(Deep Supervision),让中间层的输出也参与损失计算,迫使网络在浅层保留足够多的空间细节。
另外,数据增强时不要做太强的插值压缩。Resize到512的插值方式建议用OpenCV的INTER_AREA,它对缩小图像的细节保留更友好。如果模型推理时出现大量不连续碎片,还可以在后处理环节用形态学闭运算(cv2.morphologyEx的MORPH_CLOSE)把断裂区域连接起来。
4.3 标注边界模糊问题
道路裂纹的边缘本身就不是清晰的线条——裂纹边缘可能有细小碎渣、阴影、水渍,标注员对"哪里算裂纹、哪里不算"的判断主观性很强。这会导致模型在边界处的预测概率值很低,并且在验证集上,模型明明画出了合理的轮廓,却因为与标注不一致被判为错误。
这个问题没有完美的解法,但有两条实用经验:第一,训练时对mask做轻微的形态学腐蚀或膨胀(核大小选小,比如3x3),相当于给标注边界增加一点容忍度,让模型不用去死磕像素级对齐;第二,评估时可以考虑在计算IoU前先对预测结果做1到2个像素的边界膨胀,这样更关注整体轮廓匹配而不是逐像素对齐。这个做法在工程评估里是被认可的,但学术实验时不能这么做,需要区分场景。
4.4 格式转换与VOC适配问题
如果你后续想换用mmsegmentation,或者想用现成的COCO预训练权重,标准的VOC格式数据需要转换。这里提醒几个容易踩的坑。
mmsegmentation使用Partition格式,要求原图和mask按train、val分别存到两个文件夹,同时顶层要有classes.txt和label_map.txt。从VOC转换成这个格式时,要注意类别索引是否需要偏移,如果目标框架要求背景为255或者类别索引从1开始,不处理的话训练时类别数对不上。
自己写数据加载时,最常见的问题是路径拼接错误。Windows系统的反斜杠路径在Linux上跑,或者文件名大小写不一致(VOC标准是小写jpg,有人存成JPG),都会导致加载不到文件。我建议把所有文件名统一小写,代码开头加上路径规范化处理。
下面给一个VOC转COCO格式的简化脚本,在需要换框架时可以应急参考:
import os import json import numpy as np from PIL import Image from pycocotools import mask as coco_mask def voc_to_coco(img_dir, mask_dir, ids, output_json): annotations = [] images = [] ann_id = 1 for i, img_id in enumerate(ids): img_path = os.path.join(img_dir, img_id + '.jpg') mask_path = os.path.join(mask_dir, img_id + '.png') img = Image.open(img_path) mask = Image.open(mask_path) w, h = img.size images.append({ 'id': i + 1, 'file_name': img_id + '.jpg', 'width': w, 'height': h }) mask_np = np.array(mask) crack = (mask_np == 1).astype(np.uint8) if crack.sum() > 0: # 用RLE编码存储分割区域 rle = coco_mask.encode(np.asfortranarray(crack)) area = int(crack.sum()) annotations.append({ 'id': ann_id, 'image_id': i + 1, 'category_id': 1, 'segmentation': rle, 'area': area, 'bbox': [0, 0, w, h], 'iscrowd': 0 }) ann_id += 1 coco_json = { 'images': images, 'annotations': annotations, 'categories': [{'id': 1, 'name': 'crack'}] } with open(output_json, 'w') as f: json.dump(coco_json, f) print(f'saved {len(annotations)} annotations to {output_json}')在使用pycocotools的RLE编码时,mask必须是以Fortran顺序存储的二维numpy数组,用np.asfortranarray转一下是必须的,不然编码结果会出错。我自己第一次写转换脚本时在这个地方耗了不少时间。
5. 这套数据集后续可以怎么扩展
700张图用起来之后,很容易发现它的边界。裂纹种类不够丰富是一个明显短板,很多数据集只覆盖了常见的纵向和横向裂纹,对一些特殊形态的补丁裂纹、龟裂覆盖不足。这时候可以在现有数据集基础上采集自有数据,先人工筛选出包含裂纹的图片,再用半自动标注的方式——先用当前训练好的模型预测一遍,输出伪标签,再人工修正伪标签的错误区域,标注效率能比从零开始画多边形提升很多。
模型层面也可以往轻量化方向尝试。训练U-Net拿到基准线之后,可以换MobileNetV3或者是更小的轻量级编码器,配合ONNX导出、TensorRT加速,部署到巡检车上做实时检测。道路裂纹巡检的落地场景大多是边采集边识别,单帧推理时间要控制在几十毫秒以内,轻量化工作迟早要做。
还有一个值得尝试的方向是多尺度推理。裂纹的粗细差异很大,单尺度模型很难兼顾。推理时用三组尺寸(比如原图、0.5倍、1.5倍)分别预测,再取平均概率,能明显提升细裂纹召回率。代价是推理时间变成三倍,但作为离线数据分析的保底手段非常实用。
我个人在实际操作中的体会是:数据集的格式和标注质量往往比模型结构更影响最终效果。700张图虽然不大,但像素级标注干净、格式统一这一点,已经抵得上很多地方两三千张的杂乱数据。认真把数据检查、增强、训练、评估这套流程走完,你收获的不仅仅是一个裂纹分割模型,更是一套可以复制到其他像素级视觉任务的工作习惯。最后再分享一个小技巧:训练过程中把每个epoch的预测结果可视化出来存成图片,每10个epoch翻一翻,你就能直观看到模型在裂纹边缘上的改善过程,这种反馈对调参方向判断比看loss曲线有用得多。