做道路裂缝检测的同行应该都遇到过同样的问题:模型结构好不容易调通了,回头一看训练数据,要么是从网上零零散散拼来的,标注格式五花八门;要么自己手动标了几十张,像素标签和原图对不上,训练出来的分割结果根本没法看。我手上这套道路裂纹语义分割数据集,一共700张图,统一采用像素0、1的二值格式,按VOC目录规范整理,同时保留了mask、json、txt三种配套文件。这篇文章就围绕这套数据展开,把三个文件格式各自的用途、VOC目录组织方式、从数据到训练的加载链路,以及我在实际训练里踩过的坑一次讲清楚。
如果你是做语义分割的新手,或者正在为裂纹检测项目找一份能直接塞进训练框架的数据集,这篇文章应该能帮你省下大量折腾数据格式的时间。我会尽量用实操视角来讲,能贴代码的地方直接贴代码,能写参数的地方直接写参数。
1. 一套700张裂纹分割数据集的实际分量
1.1 为什么是700张而不是7万张——小数据集的现实逻辑
先聊一个很多人都会问的问题:700张图够用吗?我的答案是,对于道路裂纹这种结构相对固定、类别只有背景和裂纹两类的分割任务,700张是完全能支撑起一个可用模型的起步数据量。你可以把它理解成给一个初学者看700张教学图片,他看完之后至少能判断“哪里有裂缝、哪里没有”,但要做到对多种路面材质、多种光照条件下的全覆盖,确实还不够。
从任务复杂度来看,语义分割模型对数据量的需求很大程度上取决于类别数量和场景多样性。城市道路语义分割动辄上万张,是因为它要区分行人、车辆、建筑、植被等几十个类别,而且类别之间的形态差异巨大。但裂纹分割是典型的二分类问题,目标区域在像素级别上只占整张图的很小一部分,形态上又是连续的线状结构,这类任务对数据的“精准度”要求远高于对“数量”的要求。700张精细标注的图,配合合理的增强策略,已经足够让U-Net、DeepLabV3+这类常见模型跑出稳定结果。
当然,我这么说是有前提的。700张数据要真正发挥价值,必须保证三个条件:一是标注质量过关,mask和原图严格对齐;二是场景覆盖尽量分散,不能700张图全是在同一条路同一个时段拍的;三是训练集和验证集的划分要合理,不能把同一路段连续帧同时塞进两边。
在划分上,我通常采用6:2:2的比例,即420张训练、140张验证、140张测试。如果数据量再紧张一些,7:1.5:1.5也能接受。但要注意,划分的时候要打乱顺序,而且最好按“路段”而不是按“单张图”来分,这个细节我后面会专门讲,它是很容易被忽视的一个坑。
1.2 从原图到标注的构建流程
很多朋友拿到数据集之后,第一反应是直接解压、写Dataloader、开训。但如果你打算自己扩展这套数据集,或者想搞清楚数据是怎么做出来的,那么理解构建流程就非常关键。
道路裂纹分割数据集的构建链路通常是这样的:先通过车载相机或手持设备采集路面图像,然后对图像做畸变校正和裁剪,去除无关的车身、路边环境等区域,得到统一尺寸的ROI区域。接下来进入标注环节,标注工具业内常用的是Labelme这类开源多边形标注工具,沿着裂纹边缘打点,把裂纹区域框成一个或多个多边形。
这里有个细节:裂纹和背景在视觉上的边界往往并不清晰,有些细微裂纹只有凑近屏幕才能看清。所以标注时建议把图片放大到200%以上再打点,多边形顶点尽量密集,尤其是裂纹分叉和交汇的地方,顶点太少会导致标注出来的轮廓严重失真。一条贯穿整张图的裂纹,可能由十几个甚至几十个多边形片段组成,这些片段在后续生成mask时会被统一填充为像素值1。
从json到mask的转换,核心逻辑就是遍历json里的每个多边形,在空白画布上用polygon填充函数把多边形内部标记为1。下面这段代码是我在实际处理时常用的写法:
import json import numpy as np from PIL import Image, ImageDraw def json_to_mask(json_path, img_size, mask_path): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) mask = Image.new('L', (img_size[1], img_size[0]), 0) draw = ImageDraw.Draw(mask) for shape in data['shapes']: if shape['label'] == 'crack': points = [tuple(p) for p in shape['points']] draw.polygon(points, fill=1) mask.save(mask_path)这里有一个特别容易踩的坑:polygon函数要求坐标必须落在图像范围内,如果标注时有些顶点稍微超出图像边界,转换时可能抛出异常。稳妥的做法是在填充之前对所有顶点做一次clip操作,把坐标限制在[0, width]和[0, height]区间内。
2. mask、json、txt三种文件的真实用途与读取方式
2.1 mask:像素0和1背后的监督信号
先说mask文件。这套数据集里的mask采用8位单通道PNG格式,背景像素值全部是0,裂纹像素值全部是1。你可能要问了,为什么不是常见的0和255?答案是对于语义分割任务,损失函数计算时通常需要的是类别索引,而不是可视化用的灰度值。如果mask里写的是255,训练时还得做一步label = label / 255的映射,既多余又容易出错。像素0、1的格式可以直接作为交叉熵或Dice损失的输入,省掉一层预处理。
用普通看图软件打开这种mask,你会发现整张图几乎是全黑的,只有把对比度拉到最大才能隐约看到裂纹的轮廓。这是正常现象,不代表数据有问题,因为在8位灰度图里,像素值1和0在视觉上几乎没有差别。想看清楚标注效果,可以用下面这段代码把mask可视化:
import cv2 import numpy as np mask = cv2.imread('mask.png', cv2.IMREAD_GRAYSCALE) vis = np.where(mask == 1, 255, 0).astype(np.uint8) cv2.imwrite('vis.png', vis)读取mask时还有一个必须注意的地方:一定不要用cv2.imread的默认模式。默认模式会返回三通道的BGR图像,导致后续标签维度变成[H, W, 3],训练时轻则报维度错误,重则悄悄把三个通道当成多类别逻辑回归的输入,模型训出来完全不可用。正确的做法是显式指定cv2.IMREAD_GRAYSCALE,或者用PIL的Image.open(...).convert('L')。
这套数据还有一个特点:裂纹像素在整张图里的占比通常很低。我统计过这批mask,裂纹像素占比大多在0.5%到3%之间,少数裂缝密集的图能达到5%左右。这个数字对后续损失函数的选择有直接影响,我在第四章会展开讲。
2.2 json:超过二值掩膜的标注信息备份
说完mask,再说json。你可能会有疑问:既然mask已经是训练可直接用的标签了,为什么还要保留json?因为mask有一个致命短板——它是栅格化的,一旦生成,就丢失了标注过程中所有的结构化信息。
json文件里记录的是每个裂纹多边形的顶点坐标、标注的图层顺序、标签名称等原始信息。这意味着如果发现某一张图的mask有局部错误,你可以直接打开json,在标注工具里修改对应多边形的顶点,然后重新生成mask,而不需要整张图重新标注。另外,从json里你可以统计出一条图里到底有多少个独立的裂纹片段,这个信息在一些需要按条数评估的场景里非常有用。
json的具体字段结构大致如下:shapes是核心字段,它是一个列表,每一项对应一个多边形或矩形,包含label(标签名)、points(顶点坐标列表)、shape_type(标注类型)等信息。不同工具导出的json字段名略有差异,但整体结构大同小异。
如果你想从json里统计每张图的裂纹条数和分布,核心逻辑就是读取shapes列表的长度,然后按顶点坐标范围计算每个多边形的面积和位置。不过要注意,一个多边形并不一定对应一条完整裂纹,因为一条长裂纹往往被拆成多个片段来标,统计之前需要根据相邻多边形的距离做一个合并判断。
2.3 txt:三种最常见的内容约定
txt文件在不同数据集里含义差别很大,我这套数据集里的txt主要承担三类作用。第一类是类别映射文件,通常只有一行内容:0 background、1 crack,供一些读取VOC格式的框架识别类别名称。第二类是文件索引清单,比如train.txt和val.txt,里面每一行写一张图像的文件名或相对路径,供Dataloader按行读取。第三类在某些工具里也用来存放标注结果,比如把每个裂纹多边形的顶点坐标原样写入txt,方便那些不依赖json的轻量级训练脚本直接解析。
在VOC格式的数据集里,txt最常见的角色其实是ImageSets目录下的划分文件。它的组织方式很简单:train.txt里写所有训练图像的编号,val.txt里写所有验证图像的编号,每行一个。Dataloader读取所有图像路径之后,再根据这个txt过滤出属于训练集或验证集的部分。
如果你打算二次开发这套数据,建议优先把txt当作索引清单来用,因为它和ImageSets目录是配套的,改起来也最直观。追加一张新图时,只需要把新图的编号写进对应的txt,再把原图和mask放进对应目录即可,不需要改动任何代码逻辑。
3. VOC格式的目录组织决定了生态兼容性
3.1 目录结构逐层拆解
VOC格式不是什么高深概念,它的本质是一套约定俗成的目录组织规范。这套数据集沿用了VOC的核心结构,按下面这个样子摆放:
dataset/ ├── Annotations/ ├── ImageSets/ │ └── Segmentation/ │ ├── train.txt │ ├── val.txt │ └── test.txt ├── JPEGImages/ ├── SegmentationClass/ └── labels.txt逐个说。JPEGImages存放原始道路图像,命名统一为六位数字编号,比如000001.jpg,这样方便代码按字典序顺序读取。SegmentationClass存放的就是上一章说的二值mask,文件名必须和原图一一对应,比如000001.png。ImageSets/Segmentation下面放划分文件。Annotations目录在某些工具导出的VOC格式里存放每个目标的矩形框标注XML,但纯分割任务里这个目录可以是空的,或者干脆没有。
这套数据和标准VOC有一个明显的差异:标准VOC的SegmentationClass里通常有21个类别,像素值为0到20,而这里只用了0和1。所以如果你的训练框架带有类别数推断逻辑,记得显式设置num_classes=2,不要让它直接用max(label) + 1去猜。从0开始连续编号的类别索引在多数框架里都不会出问题,但保险起见,设置清楚能省掉很多不必要的调试时间。
3.2 VOC格式为什么是分割框架的默认输入
你可能会想,为什么要费劲整理成VOC格式,直接用原图和mask两个文件夹不就行了?确实能行,但VOC格式带来的最大收益是生态兼容性。绝大多数开源的语义分割项目,尤其是基于U-Net、DeepLab、PSPNet等经典结构的实现,在读取数据时默认就是按照VOC目录结构来写的。你用这套结构组织数据,意味着可以直接复用社区里现成的Dataloader和训练脚本,只需要改动数据集路径和类别数两个参数。
和COCO格式对比一下就能看出差异。COCO的分割标注是用单个json文件统一管理的,包含图片信息、标注多边形、类别映射等,这对大规模多类别数据集很友好,但缺点是结构较重,而且很多轻量级分割框架没有内置COCO加载器,需要额外写解析代码。VOC格式则把每个环节拆成文件级对应关系——原图对应mask、索引对应txt——无论用哪个框架,写起Dataloader来思路都一致,减少很多心智负担。
用表格小结一下两种格式的适用场景:
| 对比项 | VOC目录式 | COCO单文件式 |
|---|---|---|
| 文件组织 | 原图、mask分目录存放 | 图片与标注分离,json集中管理 |
| 标签形式 | 像素级PNG mask | 多边形坐标 |
| 多类别扩展 | 改像素值即可,但需同步类别文件 | 加类别映射即可,扩展方便 |
| 小项目上手速度 | 快,目录结构直观 | 慢,需要解析复杂json |
| 常见应用 | U-Net系、DeepLab系 | Detectron2系、部分实例分割项目 |
对于裂纹分割这种类别单一、目标像素级输出的任务,VOC格式几乎是性价比最高的选择。
4. 从目录到训练:加载链路的完整落地
4.1 一个可用的Dataset实现
把格式搞清楚之后,最核心的问题就是怎么把目录里的文件变成模型能吃的训练样本。下面这个Dataset实现是我在实际项目中用过的版本,基于PyTorch,兼容上述VOC目录结构,你几乎可以原样搬进自己的工程里。
import os import cv2 import numpy as np import torch from torch.utils.data import Dataset class CrackDataset(Dataset): def __init__(self, img_dir, mask_dir, split_file, transform=None): self.img_dir = img_dir self.mask_dir = mask_dir self.transform = transform with open(split_file, 'r') as f: self.names = [line.strip() for line in f if line.strip()] def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] img_path = os.path.join(self.img_dir, name + '.jpg') mask_path = os.path.join(self.mask_dir, name + '.png') image = cv2.imread(img_path) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE).astype(np.int64) if self.transform is not None: augmented = self.transform(image=image, mask=mask) image = augmented['image'] mask = augmented['mask'] image = torch.from_numpy(image).permute(2, 0, 1).float() / 255.0 mask = torch.from_numpy(mask).long() return image, mask这里必须强调两个细节。第一,mask读取后用.astype(np.int64)转成整数类型,这是PyTorch交叉熵损失对标签类型的硬性要求,不转的话会在损失计算时报类型不匹配。第二,数据增强库我建议直接用Albumentations,它最大的优势是能保证图像和mask应用完全一致的几何变换,不会出现图像翻转了但mask没跟着翻的低级错误。
4.2 数据增强不能无脑套用
在通用语义分割任务里,随机翻转、随机旋转、随机缩放几乎是标配。但在裂纹分割场景里,这些增强手段需要重新审视。裂纹是连续的线状结构,如果随机裁剪的区域太小,很可能把一条完整的裂纹拦腰截断,导致训练标签里出现大量半截裂纹,模型学到的会是碎片化的裂纹特征。
我实际使用的增强组合是这样配置的:水平翻转和垂直翻转可以放心开,因为路面裂纹的方向没有物理约束;随机旋转建议限制在小角度范围内,比如[-15度, 15度],角度太大会造成裂纹形态的过度形变;随机缩放的范围控制在[0.8, 1.2]之间,而且务必使用最近邻插值处理mask,不能用双线性插值——双线性插值会在裂纹边缘产生介于0和1之间的中间值,等于人为引入了模糊标签。
颜色方面的增强只作用于原图就好,比如随机亮度对比度调整、随机伽马校正,mask不参与任何颜色变换。有一件事很容易被忽略:不要对mask做高斯模糊或形态学平滑,哪怕是为了“去噪”也不行。mask是硬标签,只有0和1两个取值,任何引入中间值的操作都是在破坏标签的语义。
4.3 损失函数怎么配
裂纹分割最尴尬的地方在于类别极端不平衡。前面提过,裂纹像素占比常常不到3%,如果直接使用普通的交叉熵损失,模型完全可以通过把所有像素都预测为背景来获得极低的loss,训练结果就是一张全黑的mask。
我自己的做法是用Dice Loss配合Focal Loss,按8:2或7:3的权重加权求和。Dice Loss直接优化预测区域和真实区域的重叠度,天然对前景和背景的比例不敏感;Focal Loss则通过调制因子让模型把注意力集中在难分类的裂纹像素上。
class DiceLoss(torch.nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, pred, target): pred = torch.sigmoid(pred) pred_f = pred.reshape(-1) target_f = target.reshape(-1).float() intersection = (pred_f * target_f).sum() return 1 - (2.0 * intersection + self.smooth) / (pred_f.sum() + target_f.sum() + self.smooth)这里的smooth参数是平滑系数,防止分母为0,同时也起到一定的标签平滑作用。实际使用时发现,smooth设得太大反而会掩盖训练过程中的真实变化,设成1.0就好。如果模型一直在输出全背景,优先检查是不是学习率设得太大导致训练震荡,调小学习率往往能解决一大半问题。
关于优化器和学习率,我的起步配置是AdamW,学习率1e-4,batch size根据显存来,输入尺寸512x512时通常8到16都能跑。初始warmup几个epoch,让模型先适应背景分布,然后切换到主训练节奏。预热阶段的loss下降往往很慢,这是正常的,不要因为前20个epoch看不出效果就急着调参。
5. 实测训练中遇到的坑与后处理细节
5.1 训练启动时稳定预测全黑的排查思路
第一次用这套数据训练的人,大概率会遇到一个让人崩溃的现象:loss在前几个epoch很奇怪地不下降,甚至偶尔还上涨,验证集预测结果整张全黑。我当时第一次跑的时候也中招了,花了半天才定位到问题。
按经验排查顺序,先确认mask是否读对。有些框架内置的数据可视化工具如果不做0和1到0和255的映射,训练进程里的预览图会显示成全黑,造成数据出错的错觉。你可以单独从Dataloader里取一个batch,把image和mask分别保存成图片直接观察,确认数据链路没有问题。
接下来看损失值。如果Dice Loss在训练伊始就接近1,说明预测和真实区域的重叠为零,模型可能真的在输出全背景。这时候把输出层前的特征图打印出来看看数值范围,如果所有特征值都是接近0的小数,大概率是初始化或学习率问题。把学习率从1e-4降到1e-5,或者给BCE部分的权重加一个预热系数,让模型先专注学好背景分布,再逐步关注裂纹区域。
还有一个偏门的可能性是标签值域不对。如果你的mask被某个预处理环节不小心除以了255,那么标签就变成了0和0.0039,模型看到的目标区域几乎等于不存在,自然学不出任何前景。从数据集里随机挑一张mask,打印np.unique(mask),确认只包含0和1两个整数,这一步五秒钟就能排除一大类问题。
5.2 验证集划分不能简单随机
在准备阶段我就提到了按路段划分,这里具体说清楚为什么。道路裂纹图像通常是连续采集的,同一段路面会以每秒几帧甚至几十帧的速度被拍下来,相邻几帧之间几乎一模一样。如果直接随机划分训练集和验证集,极有可能出现这种情况:训练集里有一张图,它的下一帧跑到了验证集里,模型等于提前“见过”验证集答案,验证指标虚高,换一批真实场景的数据立刻现原形。
正确的划分策略是先把所有图像按来源路段分组,然后以“路段”为单位随机划分。这样可以保证同一条路段的图像只出现在一个集合里,验证结果才具备真实参考价值。这套数据在整理时已经做了路段去重和分组,但如果你准备把自己采集的图像补充进去,一定要从第一天就按这个思路处理。
训练完成后,我习惯再用一个简单粗暴的手段检测划分是否有效:把验证集在训练集上的表现和独立测试集上的表现做对比,如果两者差距超过10个百分点,第一反应不是调模型,而是检查数据划分是不是泄漏了。
5.3 后处理与评价指标
模型训练完成后,原始输出是一张概率图,每个像素的值在0到1之间。直接把大于0.5的像素判定为裂纹当然可以,但实际效果往往会有一堆小噪点,把阈值提高到0.6或0.65,噪点数量会明显下降。另一种更可控的做法是利用连通域分析,把面积小于一定阈值的预测区域直接删除。裂纹在像素上具有连续性,真正的裂缝通常会形成一条足够长的连通域,而噪声往往是孤立的几个像素点。
import cv2 import numpy as np def remove_small_regions(prob_mask, min_area=50): binary = (prob_mask > 0.5).astype(np.uint8) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8) cleaned = np.zeros_like(binary) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] >= min_area: cleaned[labels == i] = 1 return cleaned这个后处理步骤的min_area参数需要根据图像分辨率调节。在512x512的输入尺寸下,min_area取50到80比较合适;如果输入是1024x1024,裂纹在图像上的像素面积会更大,阈值可以适当提高到100以上。
最后说评价指标。裂纹分割里最常用的是mIoU,但mIoU对细长结构其实并不友好——裂纹只占图像几个百分点的面积,预测结果哪怕只是整体偏移几个像素,IoU数值都会掉得很难看。所以我建议在mIoU之外补一个结构相似性指标或者边界距离类指标,这样才能更全面地反映模型对裂纹拓扑结构的还原能力。从实用角度看,还要关注模型输出的裂纹骨架线是否和真实裂纹的走向一致,这在后续裂缝宽度估算、等级评定时比单纯的像素重叠更重要。
关于这套数据能走多远,我个人的看法是:用700张图把模型跑到可用的状态已经问题不大,但如果要部署到多样化的现实场景里,数据扩充的主要方向应该放在路面材质多样性上——不同沥青标号表面的裂缝纹理差异非常明显,同样的裂缝在水泥路面和沥青路面上呈现出的像素特征完全不同。扩充到2000张左右,配合更丰富的场景覆盖,模型的泛化能力会有一个肉眼可见的提升。训练过程中别忘了保存每个epoch的验证集输出,定期翻一翻这些可视化结果,你对模型状态的理解会比对着一堆loss曲线要深刻得多。