铁路轨道缺陷目标检测数据集实战:VOC/YOLO格式与训练部署要点解析
做铁路巡检的同行应该都有体会凌晨天窗点刚过工务兄弟扛着探伤仪在线路上一米一米地走眼睛盯到发酸一公里下来腰都直不起来。这种“人眼盯着钢轨找毛病”的老办法漏检率其实不低。尤其是腐蚀、断裂、压痕这类表层缺陷在光照差、背景复杂、列车长期碾过留下的油污干扰下肉眼很难稳定判断到底该“继续观察”还是“安排换轨”。所以这两年“智慧交通”和“铁路巡检”结合的目标检测项目越来越多。我手上这份数据集就是其中一个典型铁轨轨道腐蚀、断裂、压痕严重程度缺陷检测数据集共2324张图片、15个类别同时提供VOC和YOLO两种标注格式。这篇文章我把从数据设计、标注整理到训练部署踩过的坑一次性说清楚帮正要入坑铁路视觉检测的算法工程师和同学们省点时间。1. 项目背景与数据集设计思路1.1 铁路巡检的痛点与目标检测的切入点钢轨表面缺陷种类很多常见的有锈蚀、疲劳裂纹、剥离掉块、压痕、擦伤、轨头核伤等。传统人工巡检主要靠巡检工目测或者用探伤仪做超声波检测。超声波能发现轨头内部核伤但对表面细小的腐蚀和压痕并不敏感而且检测速度慢需要人员贴近钢轨作业安全风险也不小。这几年巡检车开始搭载高速相机一趟过去能拍几千张轨面图像。问题来了照片拍了一堆靠人看根本看不过来而且人的注意力持续集中时间有限看多了之后轻度和中度缺陷特别容易漏。目标检测模型正好补上这个环节它可以在图像里直接框出缺陷位置同时输出缺陷类型和严重程度等级。这样现场人员只需要重点复核模型给的“高风险框”不用每张图从头到尾盯。这份数据集的价值就在于它提供了已经框好、标注好“类型严重程度”的铁路轨道缺陷图像省掉从零开始采集和标注的巨大工作量。无论你是做智慧交通方向的课题还是要给某段线路做一个轨面缺陷自动巡检原型都可以拿这个数据集直接做预训练、微调和效果验证。1.2 15个类别的划分逻辑标题里只给了“15类别”没给完整类别表但从“腐蚀、断裂、压痕、严重程度”这几个关键词能猜出设计思路把“缺陷类型”和“严重程度”组合成一个个完整的检测类别。这是工业巡检数据集非常常见的设计方式。比如按严重程度分成轻度、中度、重度三档再跟缺陷类型组合可以是这样的示例缺陷类型轻度中度重度轨面腐蚀腐蚀_轻度腐蚀_中度腐蚀_重度断裂/裂纹裂纹_轻度裂纹_中度断裂_重度压痕压痕_轻度压痕_中度压痕_重度每个组合算一个类别。除了这三类通常还会加上剥离掉块、擦伤、轨头磨损、波纹磨耗、扣件异常等凑成15类。具体的类别清单要以数据集内的classes.txt为准但分类的总原则是一致的类型决定“这是什么问题”严重程度决定“需不需要马上处理”。为什么要把严重程度放进类别而不是做成回归值因为运维现场需要一个离散的、可执行的动作等级。轻度腐蚀可能只需要计划性打磨中度腐蚀要缩短检查周期重度腐蚀或断裂则意味着要申请临时天窗换轨。如果模型只输出“腐蚀”这个大类现场人员还要再去判断严重程度等于把麻烦又推回给人了这不叫智能化。1.3 2324张图片的规模是否够用2324张听着不多放在通用物体检测数据集里确实算小但做工业缺陷检测这个规模属于“能打”的水平。原因在于铁路轨道背景高度重复钢轨表面、道砟、扣件、枕木的纹理相对固定和自然场景里“椅子、猫、车”这种千奇百怪的目标完全不同。域差异小模型需要学习的“背景多样性”压力就小很多。按15类平均算每类大概150来张但由于缺陷天然长尾实际一定不平衡。断裂、重度压痕这类严重缺陷现场本来就少见可能只有几十张轻度腐蚀可能有大几百张。这种情况下不建议从头训练随机初始化的模型而是用预训练权重做迁移学习。一个在通用目标检测数据集上预训练过的backbone已经学会了边缘、纹理、形状这些基础特征迁移到钢轨缺陷上只需要微调几百张也能训练出可用效果。如果你手头有更多无标注的巡线视频还可以用训练好的模型做“伪标注”扩充再人工挑一遍低置信度样本加入训练集。所以2324张不是终点是一个很好的起点。2. 标注体系与VOC/YOLO格式详解2.1 标注字段与严重程度等级约定拿到这个数据集第一件事不是直接开训而是把标注口径摸清楚。每个目标框里存放的不只是“是什么缺陷”还有“严重程度等级”。我在整理数据时习惯先把每一类的中文名、英文标签、判定标准写在一张表里防止标着标着就跑偏。严重程度边界是标注里最容易打架的地方。比如轻度腐蚀和重度腐蚀很多人觉得凭感觉区分就行结果一批图片让三个人标重合率不到七成。我后来定的口径是腐蚀面积占目标区域比例小于30%算轻度30%~60%算中度超过60%且出现明显麻坑算重度断裂看裂纹长度和是否贯穿轨头压痕看下凹深度深度较浅且无裂纹算轻度有明显下凹且伴随裂纹算重度。这个视频和图片上可能判断不了深度所以压痕等级更多依据可见轮廓的明显程度来定。类别的英文标签也要规范。VOC格式里name字段写英文标签像corrosion_light、fracture_severe这种不要光写拼音缩写。因为后面转YOLO格式时类别ID是按字母序还是按classes.txt顺序排一旦标签写错整个数据集都乱套。2.2 VOC格式的组织方式VOC格式指的是 PASCAL VOC 的标注规范每个图片对应一个同名的XML文件。目录结构通常长这样dataset/ ├── images/ │ ├── 000001.jpg │ ├── 000002.jpg │ └── ... └── annotations/ ├── 000001.xml ├── 000002.xml └── ...一个XML文件里最关键的是size和object部分。size记录图片宽度、高度和通道数做数据校验的时候一定要拿它跟真实图片比对很多标注坐标系错位就是这里写错了。object里是目标框bndbox给出左上角和右下角坐标。我看过不少别人发的数据集XML里还带着difficult字段这个在训练时一般直接忽略但如果你要拿来做评估先确认框架有没有把它当负样本处理。一个典型的XML片段长这样annotation folderimages/folder filename000001.jpg/filename size width1920/width height1080/height depth3/depth /size object namecorrosion_light/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin512/xmin ymin340/ymin xmax780/xmax ymax520/ymax /bndbox /object /annotation这里要注意xmin、ymin是像素坐标不是归一化坐标。很多转换脚本出错都是因为把XML里的像素坐标和YOLO要用的归一化坐标混在一起算了。2.3 YOLO格式的组织方式YOLO格式和VOC思路完全不同。它不是XML而是每个图片配一个同名TXT每一行代表一个目标框class_id x_center y_center width height后面四个数字全部归一化到0~1之间也就是用像素坐标除以图片宽高。x_center是框中心点的横坐标比例width是框宽度占图片宽度的比例height同理。比如图片宽1920、高1080框的左上角是(512, 340)右下角是(780, 520)那计算过程就是x_center (512 780) / 2 / 1920 0.3365 y_center (340 520) / 2 / 1080 0.3981 width (780 - 512) / 1920 0.1396 height (520 - 340) / 1080 0.1667类别ID从0开始编号。classes.txt里的顺序一旦定了就不要再改否则所有TXT第一列的语义全变。这也是为什么我拿到一份新数据集第一件事就是打开classes.txt看一眼类别顺序再对着图片随便找几个框验算坐标。VOC和YOLO格式同时提供好处是你不用自己再做转换哪个训练框架要吃哪种格式就直接用。不过我还是建议自己动手跑一遍转换脚本因为市面上不少“双格式”数据集存在漏转、错转的情况亲手验证一遍才放心。2.4 格式转换与数据划分脚本就算数据集已经给了VOC和YOLO两份你也得学会自己转因为后续加新数据时不一定有人帮你转好。我自己常用的转换脚本不复杂核心就是把XML里的像素坐标读取出来除以宽高得到归一化坐标再写入TXT。同时按比例把数据随机划分成训练集和验证集。下面这个脚本把annotations/下的XML全部转成labels/下的TXT并按8:2划分到train和val两个文件夹import os import random import shutil import xml.etree.ElementTree as ET # 按classes.txt顺序读取类别列表 with open(classes.txt, r, encodingutf-8) as f: classes [line.strip() for line in f.readlines() if line.strip()] class_to_id {name: i for i, name in enumerate(classes)} def voc_xml_to_yolo_txt(xml_path, out_path): tree ET.parse(xml_path) root tree.getroot() width int(root.find(size/width).text) height int(root.find(size/height).text) lines [] for obj in root.findall(object): name obj.find(name).text if name not in class_to_id: print(f警告: 未知类别 {name} in {xml_path}) continue box obj.find(bndbox) xmin float(box.find(xmin).text) ymin float(box.find(ymin).text) xmax float(box.find(xmax).text) ymax float(box.find(ymax).text) # 防止边界溢出 xmin max(0, min(xmin, width - 1)) xmax max(0, min(xmax, width - 1)) ymin max(0, min(ymin, height - 1)) ymax max(0, min(ymax, height - 1)) if xmax xmin or ymax ymin: print(f警告: 非法框 {xmin},{ymin},{xmax},{ymax} in {xml_path}) continue x_center (xmin xmax) / 2 / width y_center (ymin ymax) / 2 / height w (xmax - xmin) / width h (ymax - ymin) / height lines.append(f{class_to_id[name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) with open(out_path, w, encodingutf-8) as f: f.write(\n.join(lines)) # 处理所有标注 os.makedirs(labels, exist_okTrue) for xml_name in os.listdir(annotations): if not xml_name.endswith(.xml): continue stem os.path.splitext(xml_name)[0] voc_xml_to_yolo_txt(os.path.join(annotations, xml_name), os.path.join(labels, stem .txt)) # 按8:2划分 random.seed(42) all_imgs [f for f in os.listdir(images) if f.lower().endswith((.jpg, .jpeg, .png))] random.shuffle(all_imgs) split int(len(all_imgs) * 0.8) train_imgs, val_imgs all_imgs[:split], all_imgs[split:] def copy_split(img_list, prefix): os.makedirs(f{prefix}/images, exist_okTrue) os.makedirs(f{prefix}/labels, exist_okTrue) for img in img_list: stem os.path.splitext(img)[0] shutil.copy(fimages/{img}, f{prefix}/images/{img}) if os.path.exists(flabels/{stem}.txt): shutil.copy(flabels/{stem}.txt, f{prefix}/labels/{stem}.txt) else: print(f警告: {stem} 没有对应标签文件) copy_split(train_imgs, train) copy_split(val_imgs, val)这个脚本里几个细节值得注意。第一类别不在classes里时不要直接崩溃打印警告并跳过避免一个脏标签毁了整个训练。第二坐标必须做边界裁剪因为标注软件偶尔会点到画布外面。第三划分数据时固定随机种子保证每次跑出来的训练验证集完全一致否则复现实验时结果对不上。3. 数据质量校验与预处理实操3.1 标注完整性检查拿到标注后的第一步永远不是开训而是先跑一遍完整性检查。最常出现的问题有图片存在但XML缺失、XML没有对应的图片、标签类别名不在类别列表中、TXT文件空、TXT坐标越界、一个图片里只有tiny小框等。我一般会写一个快速脚本来检查。核心思路就是遍历所有图片名确保每张图片都有标注文件再遍历所有标注文件确认里面的每个目标框都合法。格式转换脚本里已经包含了坐标合法性判断但完整性问题还需要单独跑import os images sorted(os.listdir(images)) for img in images: stem os.path.splitext(img)[0] xml_path os.path.join(annotations, stem .xml) txt_path os.path.join(labels, stem .txt) # VOC和YOLO至少一个存在 if not os.path.exists(xml_path) and not os.path.exists(txt_path): print(f缺标注: {img}) # 检查TXT是否为空 if os.path.exists(txt_path) and os.path.getsize(txt_path) 0: print(f空标注: {img})这种检查虽然不起眼但能省下后面训练失败时排错的一整天时间。我之前遇到过一个数据集有的图标注框的宽度是0模型训练时loss直接变NaN查了半天才发现是XML里有个xmax写成了xmin一样的值。所以不要嫌校验啰嗦bug往往藏在这些最basic的地方。3.2 图片信息核对与EXIF旋转问题铁路巡检照片可能来自不同相机分辨率、色深、文件格式都可能不一样。YOLO训练框架一般会做缩放但如果原图带EXIF旋转信息而标注坐标是基于旋转前图片画的那训练时就等于图片被转了一下框全歪了。解决办法是训练前统一把所有图片转成不带EXIF的RGB三通道JPEG或PNG。比如用Python批量处理from PIL import Image import os for img_name in os.listdir(images): path os.path.join(images, img_name) img Image.open(path) img ImageOps.exif_transpose(img) # 根据EXIF旋转回正 img img.convert(RGB) img.save(path, formatJPEG, quality95)另外检查一下有没有灰度图。有些巡检相机在光线不足时自动切成了灰度模式模型训练时如果某些图是三通道、某些图是单通道框架会报错或者给通道数不一致的batch。统一转成RGB最省事。分辨率差异也要留意。如果一半是1080p一半是4K直接缩放到640训练时4K图里的细小裂纹在缩放后可能变得模糊模型学不到细节。这种时候要么把4K图做滑窗裁剪要么干脆都用接近的分辨率采集。我建议在数据准备阶段按分辨率把图片分成几组训练时对高分辨率图采用随机crop采样策略效果比硬缩更好。3.3 数据增强策略缺陷样本不够怎么办缺陷检测和自然场景检测不一样你不太可能靠“随机翻转颜色扰动”就提升多少AP因为钢轨图像背景太单一翻转后还是一根钢轨多样性增加有限。但增强仍然有用重点应该放在模拟真实巡检环境的干扰上。我常用的增强组合包括亮度随机调整模拟早晚光照、对比度拉伸模拟逆光、高斯模糊模拟车体震动导致的轻微失焦、随机噪声模拟低光高ISO以及少量随机裁剪缩放。注意增强时要让目标框跟着图片同步变换手工写transform容易漏掉框所以建议直接用带bbox变换的增强库并且每次增强后都手工抽查几张图把框画出来看看位置对不对。有一种坑是增强过度。比如把轻度腐蚀图增强成严重腐蚀的样子把正常钢轨的锈迹增强成腐蚀缺陷模型就会学到“暗部纹理复杂严重腐蚀”这种错误关联。所以我更倾向于用温和的增强参数亮度扰动幅度控制在20%以内模糊半径别太大目标是增加鲁棒性而不是制造假样本。3.4 标注噪声处理与人工复核多人标注时最头疼的是同一张图不同人框的位置不一样或者一个标“轻度腐蚀”一个标“中度腐蚀”。这种噪声很伤模型因为模型会试图拟合两个矛盾的真实框。我做数据复核时会做两步第一步按“IoU一致性”过滤对同一张图的多份标注如果有计算框之间的重合度重合度低的挑出来人工看第二步按“类别混淆”过滤把所有corrosion_light和corrosion_mid的置信度预测差异做聚类找那些预测值在两个类别之间摇摆的样本重新标注。如果数据集只提供了一份标注那至少也要统计每个类别的框数量分布。严重程度等级在视觉上本身就是渐变的轻度、中度、重度之间没有绝对分界线所以不要期待标注做到百分百合。关键是规则写清楚比如“有麻坑但没剥落算中度腐蚀”然后把最典型的边缘案例绘制成参考图发给标注员对照。4. 模型训练与效果评估4.1 训练配置参考有了干净的数据集就可以训练了。以常见的目标检测框架为例我会把数据集配置写成一个配置文件里面指定训练图片路径、类别数、类别名。下面是data.yaml一个例子train: ./train/images val: ./val/images nc: 15 names: 0: corrosion_light 1: corrosion_mid 2: corrosion_severe 3: crack_light 4: crack_mid 5: fracture_severe 6: indentation_light 7: indentation_mid 8: indentation_severe 9: peeling 10: abrasion 11: wear 12: fastener_loose 13: sleeper_abnormal 14: background_defect这里第14类一般不会叫“background_defect”只是占位示意。实际操作中你完全按数据集的classes.txt来写千万不要自己改顺序。训练参数方面输入尺寸我建议先设成640x640。钢轨表面缺陷有些很小如果你用1280输入显存不够就麻烦。可以先跑一个640的baseline再看小目标的表现决定要不要升到1280。batch size尽量往大里开但受显存限制16到32比较常见。学习率用0.01的话配合预热用自适应优化器的话0.001到0.0001也可以。Epoch先设100配合早停看验证集loss不再下降就停。4.2 模型评估指标mAP、PR曲线、混淆矩阵不要只看一张总mAP数就完事。铁路缺陷检测是“宁可漏检率低误检还可以人工确认”所以评估时要把每个类别的AP单独看再结合混淆矩阵分析。mAP0.5指的是IoU阈值0.5下的平均精度mAP0.5:0.95则更严格IoU从0.5到0.95取平均。工业场景我一般以mAP0.5为主要指标因为缺陷框不需要跟真实框贴合得特别完美能定位到位置就行。真正要关心的是漏检率也就是某个严重缺陷类别的召回率。轻度腐蚀漏检影响不大但“断裂_重度”漏检一次可能就要出安全事故。所以训完要看每个类别的Recall如果类别重要性不同甚至可以自定义加权评估项。混淆矩阵能直观看出哪些类互相认错。比如轻度腐蚀经常被认成中度腐蚀说明等级边界标注得模糊压痕被认成正常锈蚀说明正常锈蚀的负样本不够。根据混淆矩阵回头补样本比盲目调学习率有效得多。4.3 不同缺陷类别的表现差异分析在我做实验的时候断裂类往往AP最高因为裂纹形态尖锐、纹理对比强很显眼。压痕类次之因为它通常会伴随轨头表面凹陷光影变化比较容易捕捉。最难的是轻度腐蚀它跟普通锈迹在视觉上几乎一样而且目标边缘模糊框怎么打都不准。如果某类别AP特别低先别急着调结构。检查三件事样本数量是不是太少、标注框是不是特别大而目标实际很小、训练时有没有被增强过度搞糊。样本太少就做针对性重采样把该类的重复次数提高或者用复制粘贴的方式在背景图里粘贴真实缺陷样本。标注框过大则重新细化标注把小缺陷单独框出来而不是一个框盖住整片区域。增强过度就把亮度扰动和模糊关掉再训一版对比。对于小缺陷可以试试给高分辨率图做切图推理也就是把原图裁成几个重叠的patch分别检测再把框映射回原图。这个逻辑能显著提高小目标召回率代价是推理时间变长。在巡检车场景下一般都能容忍几百毫秒的耗时所以值得试。4.4 边缘部署与推理优化模型最终要在巡检设备上跑不一定有高性能GPU。这时候要做推理加速。第一步把训练好的模型导出成一个推理引擎支持的格式比如通用ONNX第二步做半精度或整数量化第三步把输入分辨率从640降到416或320看AP掉多少。很多缺陷在量化后小目标损失很大所以需要仔细评估。我在实际部署中常用的一个策略是“两级检测”先用一个轻量模型快速过滤出有缺陷嫌疑的区域再用一个高精度模型对嫌疑区域做大图精细检测。轻量模型可以接受更多的误检重点是不漏精细模型只需要处理少量区域即使输入分辨率高也能跑得过来。这套方案对计算资源有限、又要求实时性的工业现场特别实用。部署时还要考虑置信度阈值。离线测试用0.5的阈值是为了看模型能力上限线上运行建议把阈值设到0.7以上宁可不报也不要乱报因为误报太多会让现场人员麻木最后真缺陷也不会被当回事。当然阈值怎么定要结合现场故障率来调没有万能数字。5. 工程落地中的常见坑与经验总结5.1 数据不平衡与难样本挖掘铁轨缺陷数据集天然有长尾分布轻度腐蚀几百个框重度断裂可能只有十几个框。如果不做处理模型会为了整体loss好看而偏向多数类少数类几乎学不出来。应对办法分几种。第一种最简单从多数类里随机抽取一部分控制比例。第二种是重复采样少数类一份当三份用。第三种是难样本挖掘先用一个训好的模型去跑无标注数据把那些预测置信度居中、模棱两可的图片挑出来人工标注这种样本对提升模型区分能力最有帮助。我自己的经验是优先补“边界状态”样本也就是正在从轻到中、从中到重过渡的样本比补一些极典型的重度样本更有用。5.2 遮挡、光照、雨天、锈迹干扰现场图片和数据集里“干净”的样本往往差很多。钢轨上经常有油污、泥沙、雨水反光、道砟碎屑遮挡甚至还有杂草的影子。这些干扰会让模型产生两类典型错误一是把油污反光当成压痕二是把正常锈迹当成腐蚀缺陷。解决思路是增加负样本。如果数据集里没有“正常锈蚀”这个类别你就得专门收集一批“看着像缺陷但不是缺陷”的图片设成一个背景类或者在训练时用无标签的负样本参与损失计算。有了负样本模型才能学到“仅凭颜色深不等于腐蚀”。部署时还可以加一个规则检测框在轨道表面投影区域之外的直接忽略因为缺陷不会出现在道砟或者枕木上用几何先验去掉大部分误检。5.3 误检漏检排查思路模型训完发现误检多不要急着调参数。先把badcase可视化把预测框画出来按类别和置信度排序。我一般会统计两类高置信度误检和高置信度漏检。高置信度误检说明模型学到了错误的特征高置信度漏检说明模型的能力边界就在这里需要附加信息。排查时我会先看是不是标注错误导致的。如果某个误检框重叠的区域在真实标注里也有一个不同类别的框那可能是标注label颠倒了。再看是不是目标太小模型输出层的stride太大小目标特征已经丢失这种情况可以放大输入或做patch检测。还要看有没有类别间语义混淆比如轻度压痕和轻度划伤人眼都分不清模型能分清才怪。这时候要么合并类别要么给更细的标注规则。5.4 从Demo到产线的避坑建议一个能在测试集上mAP 0.85的模型直接扔到现场大概率会“水土不服”。现场相机安装角度、曝光参数、车速、抖动都跟训练数据不完全一致。所以一定要在部署前做域适配采集一段现场视频用模型跑一遍手动标几十张代表性badcase加入训练集再微调一轮。我见过很多项目死在“测试集表现好但现场不能用”这一步。原因不是模型差而是数据分布变了。工业视觉落地没有一劳永逸巡检车每次换相机、换光源、换季节都可能影响效果。所以数据管线里要留出“持续收集badcase”的入口每跑一批现场数据就把低置信度和高置信度错判的图片保存下来定期回灌训练集。这样模型才会越用越稳。另外缺陷检测的检出结果一定要有可追溯性。保存推理时的原因图、框坐标、置信度和缺陷类别方便现场人员复核。不要只给一个“有缺陷”的结论还要告诉他们大概在哪个里程、哪个位置、严重到什么程度否则算法做得再好运维人员也不敢真正信任它。最后再分享一个小经验。我在整理这个数据集的时候最深的体会是模型能跑通只占三成最花时间的是标注口径的统一和数据质量的清洗。如果你也准备做铁路巡检或者类似的工业视觉检测请先把类别定义死死锁住再做标注最后再训模型。还有个小技巧训练前把每一类的样本数量打印出来数量和AP往往强相关哪个类数量少就重点补哪个这比熬夜调参有用得多。