ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VOC火车检测实战:从数据转换到YOLO训练与长条目标优化

VOC火车检测实战:从数据转换到YOLO训练与长条目标优化 简介VOC火车检测数据集面向目标检测方向的开发者与研究者聚焦单一类别“火车”的识别与定位任务可用于铁路安全监控、交通管理等场景下的模型训练与验证。资源包共790个文件包含263张jpg图像、263个xml标注文件与264个txt标注文件压缩包约24.47MBxml提供边界框坐标、类别名称及难度等级等结构化元数据txt则以坐标形式记录目标位置两者配合可满足Faster R-CNN、YOLO、SSD等主流框架的数据读取需求。目前已有525人学习下载。借助这批标注实例读者可完成数据解压、格式转换、图像预处理与数据增强等流程并在此基础上训练模型、计算mAP评估检测性能为算法调参与泛化能力提升提供可复用的实验基础。1. 拿到 train_VOCtrainval2007.zip 之后火车检测这件事到底难在哪如果你手里正躺着一个叫train_VOCtrainval2007.zip的压缩包想用它训练一个能识别火车的检测模型那这篇就是写给你的。VOC 格式是目标检测里最经典的标注规范之一train_VOCtrainval2007这种命名通常意味着它是按 PASCAL VOC 2007 的目录结构组织、面向训练与验证的图片加 XML 标注集合里面大概率只有「火车」这一个类别或者以火车为主类。它解决的核心问题是让你不用从零标注直接进入模型训练和调参环节。但火车检测和检测猫狗完全不是一回事。火车是超长目标一列货运列车在画面里可能横跨整张图长宽比动辄 10:1 甚至 20:1同时它又经常出现在远景、逆光、雨雾、站台遮挡的场景里。新手拿到数据集第一反应是直接喂给 YOLO结果训练 loss 降得挺好看一推理发现模型把整条铁轨、甚至电线杆都框成火车。这篇就按「先看懂数据 → 转格式 → 训练 → 排坑 → 进阶」的顺序把这条链路走通。2. 先拆开压缩包VOC 目录结构与火车标注的三个特征2.1 VOC 标准目录长什么样解压之后正常的 VOC 结构应该是这样的。先别急着写训练脚本用几条命令把家底摸清楚这一步能省掉后面大量返工。# 解压到独立目录避免污染原始压缩包 unzip train_VOCtrainval2007.zip -d voc_train # 查看顶层结构 ls voc_train # 统计图片数量VOC 图片统一放在 JPEGImages ls voc_train/JPEGImages | wc -l # 统计标注文件数量XML 放在 Annotations ls voc_train/Annotations | wc -l # 检查图片与标注是否一一对应数量不一致就是坑 comm -3 \ (ls voc_train/JPEGImages | sed s/\.[^.]*$// | sort) \ (ls voc_train/Annotations | sed s/\.xml$// | sort)最后那条comm命令是关键它把图片文件名去掉扩展名、标注文件名去掉.xml排序后对比输出为空才说明一一对应。只要有一边多出来训练时就会报「找不到标注」或者静默跳过样本。标准 VOC 目录里通常还有ImageSets/Main/文件夹里面是train.txt、val.txt、trainval.txt这类纯文件名列表。如果压缩包里没有这个文件夹说明划分要你自己做后面 2.3 会讲。2.2 火车标注的三个典型特征打开几个 XML 看看你会发现火车标注和普通目标有几个明显差异这直接决定后面参数怎么设。import xml.etree.ElementTree as ET import glob import os # 遍历所有标注统计宽高比分布判断长尾目标有多严重 ratios [] sizes [] for xml_path in glob.glob(voc_train/Annotations/*.xml): tree ET.parse(xml_path) root tree.getroot() for obj in root.findall(object): name obj.find(name).text bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) xmax float(bbox.find(xmax).text) ymin float(bbox.find(ymin).text) ymax float(bbox.find(ymax).text) w xmax - xmin h ymax - ymin if h 0: ratios.append(w / h) sizes.append((w, h, name)) import statistics print(标注框总数:, len(ratios)) print(宽高比中位数:, statistics.median(ratios)) print(宽高比最大值:, max(ratios)) print(宽高比 5 的占比: %.1f%% % (100 * sum(r 5 for r in ratios) / len(ratios)))跑完你会看到类似「宽高比中位数 3 以上、最大值超过 15、超过 5 的占比两三成」的结果。这就是火车检测的第一个特征极端长宽比。普通 anchor 是按正方形附近设计的直接套用会导致长条目标召回极低。第二个特征是目标尺寸跨度大近景车头可能占满半张图远景整列车只有几十像素宽。第三个特征是类别单一但场景杂只有 train 一类但背景里有站台、铁轨、接触网、隧道模型很容易把这些共现元素当成火车的一部分。2.3 没有划分文件时怎么切 train/val如果ImageSets/Main/是空的别用随机切分火车图片往往同一列车连续多帧随机切会导致训练集和验证集里出现几乎相同的画面验证指标虚高。按文件名前缀或拍摄序列切更靠谱。import os import random img_dir voc_train/JPEGImages names [os.path.splitext(f)[0] for f in os.listdir(img_dir)] names.sort() # 按排序后前 80% 做训练后 20% 做验证避免同序列泄漏 split int(len(names) * 0.8) train_names names[:split] val_names names[split:] os.makedirs(voc_train/ImageSets/Main, exist_okTrue) with open(voc_train/ImageSets/Main/train.txt, w) as f: f.write(\n.join(train_names)) with open(voc_train/ImageSets/Main/val.txt, w) as f: f.write(\n.join(val_names)) print(train:, len(train_names), val:, len(val_names))这里用排序后切分而不是random.shuffle是因为 VOC 数据集的文件名常带序号同序列图片序号相邻排序切分天然把连续帧分到同一侧。如果你的文件名完全无规律那就退而求其次先按文件名聚类再切。3. 把 VOC 转成 YOLO 格式转换脚本与四个边界坑3.1 为什么训练前要转格式现在主流检测框架里YOLO 系列吃的是「每张图一个 txt每行类别 cx cy w h坐标归一化到 0~1」的格式而 VOC 是 XML 里存绝对像素坐标的xmin ymin xmax ymax。两者不转通就没法直接训练。转换本身不难难的是边界情况处理下面这个脚本把常见的坑都堵上了。import xml.etree.ElementTree as ET import os import glob # 类别映射单类别数据集固定为 0多类别按需扩展 CLASS_MAP {train: 0} def convert_bbox(size, box): VOC 绝对坐标 - YOLO 归一化中心点坐标 dw 1.0 / size[0] dh 1.0 / size[1] xmin, xmax, ymin, ymax box # 关键先裁剪到图像范围内防止越界标注产生负宽高 xmin max(0, min(xmin, size[0])) xmax max(0, min(xmax, size[0])) ymin max(0, min(ymin, size[1])) ymax max(0, min(ymax, size[1])) x (xmin xmax) / 2.0 y (ymin ymax) / 2.0 w xmax - xmin h ymax - ymin return (x * dw, y * dh, w * dw, h * dh) def convert_one(xml_path, out_dir): tree ET.parse(xml_path) root tree.getroot() size root.find(size) w int(size.find(width).text) h int(size.find(height).text) lines [] for obj in root.findall(object): name obj.find(name).text.strip() if name not in CLASS_MAP: continue # 跳过非目标类别避免污染训练 bbox obj.find(bndbox) box ( float(bbox.find(xmin).text), float(bbox.find(xmax).text), float(bbox.find(ymin).text), float(bbox.find(ymax).text), ) bb convert_bbox((w, h), box) # 过滤掉宽或高为 0 的退化框 if bb[2] 0 or bb[3] 0: continue lines.append(f{CLASS_MAP[name]} .join(f{v:.6f} for v in bb)) if not lines: return False # 无有效目标不生成空文件 base os.path.splitext(os.path.basename(xml_path))[0] with open(os.path.join(out_dir, base .txt), w) as f: f.write(\n.join(lines)) return True out_dir voc_train/labels os.makedirs(out_dir, exist_okTrue) ok, skip 0, 0 for xml_path in glob.glob(voc_train/Annotations/*.xml): if convert_one(xml_path, out_dir): ok 1 else: skip 1 print(转换成功:, ok, 跳过:, skip)逻辑上分四步读 XML 拿图像宽高、遍历每个 object、把绝对坐标转成归一化中心点坐标、写 txt。参数上CLASS_MAP是唯一需要你按数据集实际情况改的地方如果 XML 里类别名不是train改成对应字符串即可。f{v:.6f}保留六位小数足够精度又不会让文件过大。3.2 四个必须处理的边界坑第一个坑是坐标越界。VOC 标注里偶尔出现xmax大于图像宽度的情况直接算会得到大于 1 的归一化值训练时被框架截断或报错。脚本里min(xmax, size[0])就是干这个的。第二个坑是退化框。xmin xmax的框宽为 0转出来是无效样本必须过滤否则某些框架会直接崩。第三个坑是空标注文件。有些图确实没有火车转换后不该生成空 txt因为部分框架会把空文件当成「有图无目标」的正样本干扰训练。脚本里if not lines: return False就是跳过。第四个坑是类别名带空格或大小写不一致。XML 里写Train、train的情况都遇到过strip()只能去首尾空格稳妥做法是统一转小写再查表。3.3 转换后自检三行命令验证格式转完别急着训练用下面几条命令快速验证能挡掉八成低级错误。# 1. 标签文件数应与有效图片数一致 ls voc_train/labels | wc -l # 2. 抽查一个标签确认每行 5 个字段且坐标在 0~1 head -3 voc_train/labels/$(ls voc_train/labels | head -1) # 3. 用 awk 检查是否存在越界坐标输出应为空 awk {if ($20||$21||$30||$31||$40||$41||$50||$51) print FILENAME: $0} voc_train/labels/*.txt第三条命令是血泪经验越界坐标在训练初期不会报错但会让 loss 出现莫名其妙的尖刺排查起来很费时间不如转换后一次性查干净。4. 训练配置anchor、输入尺寸与长条目标的取舍4.1 输入尺寸不是越大越好火车是长条目标直觉上把输入尺寸拉大能保留更多细节。但输入从 640 拉到 1280显存占用大约翻四倍训练速度掉一半以上。更关键的是如果你的数据集里远景小火车居多单纯放大输入对召回提升有限因为小目标在缩放后依然小。我的做法是先按 640 跑一版基线看验证集里漏检的是近景大车还是远景小车。如果漏检集中在小目标再考虑 960 或 1280同时配合 mosaic 增强。如果漏检集中在大车的局部比如只框住车头没框住车厢那问题在 anchor 和 NMS不在输入尺寸。4.2 anchor 要按你的数据集聚类别用默认值默认 anchor 是在 COCO 上聚类的对长条目标不友好。用 k-means 在自己的标注上重新聚一版效果立竿见影。import numpy as np import glob # 读取所有 YOLO 格式标签的宽高已归一化 wh [] for txt in glob.glob(voc_train/labels/*.txt): with open(txt) as f: for line in f: parts line.split() if len(parts) 5: wh.append([float(parts[3]), float(parts[4])]) wh np.array(wh) # k-means 聚类k 取 9对应 3 个尺度各 3 个 anchor from sklearn.cluster import KMeans k 9 kmeans KMeans(n_clustersk, n_init10, random_state0).fit(wh) centers kmeans.cluster_centers_ # 按面积排序方便分配到不同检测尺度 centers centers[np.argsort(centers[:, 0] * centers[:, 1])] for c in centers: print(fanchor: {c[0]:.4f} {c[1]:.4f})聚类出来的 anchor 会明显偏「扁」宽高比大的簇占多数。把结果填进模型配置的 anchor 字段注意要按面积从小到大排列小 anchor 分给小尺度特征图。这一步做完长条目标的召回通常能涨几个点。4.3 数据增强里哪些能用哪些要慎用火车检测里mosaic 和随机缩放基本可以放心用它们能增加尺度多样性。但随机旋转要慎用火车在真实场景里几乎不会大角度倾斜强行旋转会让模型学到不存在的姿态验证集上反而掉点。上下翻转也要慎用因为火车和铁轨、接触网的相对位置是有物理约束的翻转后接触网跑到下面模型会困惑。水平翻转一般安全但要注意如果数据集里火车有明确的行进方向特征比如车头灯位置翻转会破坏这个线索需要评估后再决定。裁剪增强要控制比例裁得太狠会把长条目标切成两段产生错误的监督信号。5. 避坑与排查火车检测训练中最容易翻车的五件事5.1 现象loss 正常下降但 mAP 极低原因通常是标注格式和框架预期不一致。比如框架要class cx cy w h你给的是class xmin ymin xmax ymax训练照样跑loss 也降但框的位置全错。解决方法是拿一张图把预测框和标注框画在一起肉眼比对别只看数字。5.2 现象模型把整条铁轨框成火车原因是铁轨和火车在训练集里高度共现模型学到了虚假相关。解决办法是在数据增强里加入随机裁剪让部分图片只保留铁轨不保留火车作为负样本同时检查是否有标注把铁轨误标成了火车。这个坑很隐蔽因为验证集如果也全是「火车铁轨」的组合指标看不出来。5.3 现象远景小火车全部漏检先确认不是输入尺寸问题再看 anchor 最小那组是不是还是太大。火车数据集里小目标可能只有 20 像素宽如果最小 anchor 对应 32 像素那必然漏。重新聚类时把 k 调大一点或者单独为小目标加一组更小的 anchor。另外检查标签里小目标的宽高是不是被归一化后小于 0.01某些框架会过滤掉过小的框。5.4 现象同一列车被框出好几个重叠框这是 NMS 阈值问题。长条目标在 NMS 里 IoU 计算容易偏低导致本该合并的框被保留。把 NMS 的 IoU 阈值从默认 0.45 调到 0.5~0.6 试试或者改用 DIoU-NMS它对长条目标的重叠判断更合理。注意阈值调太高会误合并相邻的两列车要看着验证集调。5.5 现象训练到一半 loss 突然变 NaN常见原因是学习率过大加上长条目标的梯度不稳定或者数据里混入了宽高为 0 的退化框。先按 3.3 的 awk 命令查一遍标签确认没有越界和退化框再把学习率降一半加梯度裁剪。如果还不行检查是不是某张图片本身损坏用 PIL 批量打开一遍能筛出来。6. 进阶用切片推理救回长条目标的召回前面讲的都是训练侧但火车检测有个推理侧的技巧值得单独说切片推理。当一列车在整图里横跨太宽直接缩放到网络输入尺寸后车厢细节会被压没模型只能框出车头附近。切片推理的思路是把大图切成有重叠的小块分别检测再把结果映射回原图合并。import numpy as np def slice_infer(image, model, slice_size640, overlap128, conf0.25): 对大图做重叠切片推理返回原图坐标系下的检测框 h, w image.shape[:2] stride slice_size - overlap all_boxes [] # 按 stride 滑动切块保证边缘目标至少完整出现在一个块里 for y in range(0, h, stride): for x in range(0, w, stride): patch image[y:y slice_size, x:x slice_size] if patch.shape[0] 32 or patch.shape[1] 32: continue # 太小的边缘块跳过 dets model(patch, confconf) # 假设 model 返回 [x1,y1,x2,y2,score] for d in dets: # 把块内坐标加回偏移映射到原图 all_boxes.append([ d[0] x, d[1] y, d[2] x, d[3] y, d[4] ]) # 跨块合并用 NMS 去掉重叠区域重复检测的框 return nms(all_boxes, iou_thr0.5)参数上slice_size一般取和训练输入一致overlap取 slice_size 的 15%~25%太小会漏掉正好卡在切缝上的目标太大则重复计算拖慢速度。conf可以比整图推理时设低一点因为切片后目标变大模型更有信心低阈值能多召回一些。这个方法的代价是推理时间随切片数线性增长一张 4000 像素宽的图切下来可能要跑十几块。所以我的习惯是先用整图推理跑一遍把置信度低于阈值的区域挑出来只对这些区域做切片二次检测兼顾速度和召回。这个策略在长条目标场景里比无脑切片实用得多。最后说个我自己的教训早期做火车检测时我盯着 mAP 调了两周涨了不到两个点后来把验证集里所有漏检样本导出来一看八成漏检都是同一种场景——黄昏逆光下的远景列车。与其在通用参数上磨不如针对这类难样本做定向增强或者干脆单独训一个逆光分支。数据集的短板往往比模型的短板更值得先补。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进