ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

智慧交通打场晒粮检测:VOC/YOLO双格式数据集实战指南

智慧交通打场晒粮检测:VOC/YOLO双格式数据集实战指南

简介:面向智慧交通城市道路场景的目标检测数据集说明文档,服务于需要训练“打场晒粮”识别模型的算法工程师与科研人员。文档详细描述了数据集的构成:共1065张JPEG图片,每张图片均配有Pascal VOC格式XML标注与YOLO格式TXT标注,类别为“shailiang”,标注框总数达1245个,可用于评估数据规模、标注密度与格式兼容性。文档同时提供了标注工具、标注规则及下载地址,方便读者直接获取原始数据。资源包包含1个docx文件,大小约2.32MB,为纯文字说明类文档。目前已有63人学习浏览,适合正在构建智慧交通违章行为检测、城市道路安全分析等相关项目的中高级开发者参考,有助于快速判断该数据集是否匹配自身训练需求,并获取后续数据接入与格式转换的准确依据。

1. 打场晒粮检测不是“锦上添花”:城市道路上的真实痛点

每年收晒季,城市外围道路和城乡结合部的机动车道常被铺满谷物,这不仅堵车,更是事故隐患。人工盯着几十路视频找这种“不规矩”行为,十分钟就会疲劳;通用检测模型又往往把晒粮、树影和路面裂缝混为一谈。“智慧交通城市道路打场晒粮检测数据集VOC+YOLO格式1065张1类别”这个标题里,最有价值的不是“智慧交通”,而是这1065张已经标好的图片和VOC+YOLO双格式标注——它让传统目标检测模型可以直接拿来做迁移学习,识别占用车道晒粮。它适合正在做智慧交通视频分析的人,也适合想快速验证“专用小模型能不能替代人工巡查”的算法团队。

2. 先看懂数据集再动手:VOC与YOLO两种标注格式的差异和选用

数据集解压后通常是两套目录并排:VOC风格下是JPEGImages和Annotations,XML里存框坐标;YOLO风格下是images和labels,txt里存归一化坐标。很多人拿到手第一件事就是跑训练命令,结果经常报标签格式错误。先花十分钟搞清两种格式的差异,后面能省一天排错。

2.1 一张图两张脸:VOC的XML与YOLO的TXT各自记录了什么

VOC标注最常见的形式是每个xml对应一张同名图片。打开xml能看到目标类别、图像宽高和bndbox里的xmin、ymin、xmax、ymax,这是绝对像素坐标,坐标原点在左上角。下面是一个典型XML片段,文件名和框坐标只是示例,真实数据集中每个文件结构类似。

<annotation> <folder>JPEGImages</folder> <filename>road_sun_001.jpg</filename> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <object> <name>sun_grain</name> <bndbox> <xmin>412</xmin> <ymin>286</ymin> <xmax>1644</xmax> <ymax>801</ymax> </bndbox> </object> </annotation>

YOLO格式沿用的是同一套目标,但存储方式完全不同。每个txt文件里每行代表一个目标框,五个字段分别是类别索引、归一化中心点x、中心点y、归一化宽、归一化高。坐标值在0到1之间,小数精度至少保留到6位。同样是上图那个场景,对应txt可能是下面这样。

0 0.535 0.503 0.642 0.477

看出来了吗?VOC是左上右下角点,YOLO是中心点加宽高。VOC里类别名是字符串sun_grain,YOLO里类别索引0。很多人在写转换脚本时会忽略一件事:YOLO的宽高需要除以图像宽高,中心点也要除以宽高,而不是把xmin除以宽、把ymax除以高。如果转完发现框跑到左上角挤成一团,基本都是中心点公式写错了。我一般会先在转换脚本里加一个复核函数:把YOLO的txt转回像素坐标,与XML里的原框画在图上对比,肉眼扫过十张没问题再拿去训练。

2.2 1065张、1类别的实际规模:够不够训练一个能用的检测器

1065张单类别,听起来不多。对于一个分类任务可能不够,但检测任务的样本量要看目标框数量。打场晒粮常是成片出现,一张图里可能有两三个大框,所以有效正样本框数很容易超过2000个。更重要的是单类别目标外观高度一致:“谷物摊在路面上”这个模式,比行人、车辆这种类内差异很大的目标更容易收敛。我用这类数据训练的经验是,只要场景分布别太单一,1000张左右完全能训出一个可用的模型。

但有一个前提:图像多样性。如果1065张全是从同一路段、同一个摄像头覆盖角度采集的,模型会把镜头角度一起学进去,换个路口就失效。建议训练前先做一个快速统计,看图像尺寸分布和标注框在整图里的面积占比。下面这段代码读取VOC的XML,逐个文件统计每张图的框数量,并计算最大框面积占图像面积的比例。

from pathlib import Path from xml.etree import ElementTree as ET xml_dir = Path("Annotations") for xml_file in sorted(xml_dir.glob("*.xml"))[:20]: root = ET.parse(xml_file).getroot() w = int(root.find("size/width").text) h = int(root.find("size/height").text) obj_list = root.findall("object") max_ratio = 0 for obj in obj_list: 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) area = (xmax - xmin) * (ymax - ymin) max_ratio = max(max_ratio, area / (w * h)) print(f"{xml_file.stem}: 目标数={len(obj_list)}, 最大框面积占比={max_ratio:.2%}")

跑完后如果发现最大框面积占比普遍超过50%,说明目标很大;如果框集中在图像底部,说明拍摄角度都是俯视,这样在部署前要专门补充正视角样本。面积占比这个数也影响后续训练图片尺寸选择:大目标可以适当降低输入分辨率,小目标才需要提高分辨率。

2.3 数据划分:8:1:1里的陷阱与随机种子

YOLO训练前必须先划分训练集、验证集、测试集。常见做法是8:1:1。但划分不能只按文件名随机切,还必须保证:图片、XML、TXT三个文件同步移动;避免同一个场景的连续帧同时进入训练集和测试集,否则验证指标虚高。下面这段脚本会用固定随机种子划分,并强制按stem同步三类文件。

import random import shutil from pathlib import Path random.seed(42) img_dir = Path("JPEGImages") xml_dir = Path("Annotations") yolo_img_dir = Path("images") yolo_txt_dir = Path("labels") stems = sorted(p.stem for p in yolo_img_dir.glob("*.jpg")) random.shuffle(stems) n = len(stems) train_stems = stems[:int(n * 0.8)] val_stems = stems[int(n * 0.8):int(n * 0.9)] test_stems = stems[int(n * 0.9):] for split_name, split_stems in [("train", train_stems), ("val", val_stems), ("test", test_stems)]: (Path("split") / split_name / "images").mkdir(parents=True, exist_ok=True) (Path("split") / split_name / "labels").mkdir(parents=True, exist_ok=True) for stem in split_stems: shutil.copy(yolo_img_dir / f"{stem}.jpg", Path("split") / split_name / "images" / f"{stem}.jpg") shutil.copy(yolo_txt_dir / f"{stem}.txt", Path("split") / split_name / "labels" / f"{stem}.txt")

脚本看起来长,核心只有两步:random.shuffle得到顺序,然后把同名文件复制到对应位置。为什么要复制而不是移动?因为原始标注文件有时还要回改,留一份原始目录更保险。随机种子固定成42,保证每次重跑结果一致。如果数据集同时有VOC和YOLO格式,建议先把两种格式都同步划分好,避免训练时发现标签缺失。测试集不是必须喂给YOLO的,但建议保留,等模型训练结束后统一评估一次再定版本。

3. 从数据集到YOLO模型:训练流程与关键参数

3.1 写好data.yaml:类别、路径与相对路径陷阱

YOLO训练的第一件事不是写模型,而是写data.yaml。训练脚本从yaml文件里拿三个信息:数据集根目录、train与val的图片路径、类别列表。标题里的数据集只有1类,所以names里只有一个sun_grain。一个最小可用的yaml是这样:

# data.yaml path: /你的绝对路径/道路打场晒粮数据集 train: images/train val: images/val test: images/test # 可留空或注释掉 nc: 1 names: 0: sun_grain

有个参数很容易炸:path。如果path写成相对路径,训练脚本的当前工作目录一旦不是数据集根目录就找不到图片。我一般直接把path写成绝对路径。相对路径的一个例外是,数据集已经被按images/train、labels/train的标准结构摆好,且path指向项目根目录,train写images/train。这里特别提醒:YOLO读取标签时不会去读xml,而是把图片路径里的images换成labels再去找同名txt。所以labels目录必须和images目录同级,且文件名一一对应。

如果数据集解压后只有images和labels两个平铺目录,没有train/val子目录,那就必须先用第2章的划分脚本做好目录结构,再写data.yaml。不要把train直接指向images,否则模型会把验证集图片也当成训练样本,评估指标全部失真。

3.2 选预训练权重还是从零训练:1类目标迁移是捷径

1个类别的小数据,优先用预训练权重做迁移学习,而不是从零初始化。原因是打场晒粮的边缘、纹理、光照变化与通用目标检测底层的视觉特征高度共享;从零训练不仅收敛慢,还可能在小数据上过拟合。通常我会选一个轻量的预训练模型,比如n或者s规格,先跑通流程,验证集mAP达标后再考虑更大的模型。视频分析场景推理速度也很重要,n量级的参数量在边缘设备上更容易落地。

输入分辨率imgsz怎么选:打场晒粮是大片连续目标,不是密集小目标,一般640就够。如果希望把较远处的晒粮也召回,可以练到1024或1280,但显存占用和训练时间会明显上涨。一个务实的做法是先用640训练,再用训练好的best.pt在1280下验证,观察mAP有没有明显提升;没有提升就不要硬上大分辨率。

3.3 训练命令与参数调节:epochs、batch、imgsz、patience

下面是一段我常用的训练命令,按第2章划分好的目录结构可以直接跑。YOLO训练入口用detect子命令,数据和模型参数都放在命令行里。

yolo detect train \ data=/你的绝对路径/split/data.yaml \ model=yolov8s.pt \ epochs=120 \ batch=16 \ imgsz=640 \ patience=20 \ project=runs \ name=sun_grain_det \ cache=ram

逐项拆开看:model=某个预训练权重文件,如果换成一个yaml文件就是随机初始化从零训练;epochs=120不是死数,配合patience=20,早停触发后会停在最佳轮次附近,实际训练轮数可能只有六七十;batch=16在1065张图上约等于每轮67个iter,如果GPU显存只有8GB就降到8,学习率一般会自动缩放,但不缩放也能接受。imgsz=640是短边缩放到640后做letterbox。cache=ram适合一两千张的小数据集,把全部图片缓存进内存能省掉大量磁盘IO,训练速度提升明显。cache=disk也可以,但占用更多临时空间。

训练结束后看runs输出目录。通常会有args.yaml、weight里best.pt和last.pt、以及result曲线。判断模型能不能用,关键看验证集mAP50,而不是训练集的loss。晒粮是单类目标,mAP50在0.85以上基本可用。mAP50-95偏低不用执着,那是不同IoU阈值下的综合值,对大面积目标未必那么重要。如果训练过程中验证集loss一直下不来,先回数据层面找问题,而不是狂加epoch。

4. 避坑:VOC转YOLO、小样本训练与误检的排查清单

这部分按现象、原因、解决来讲,列四条我在单类别道路数据上踩过的坑。每一条都能单独对应一次训练翻车现场。

4.1 坐标越界和空标注:转格式后训练报错的集中来源

现象:转完YOLO格式,训练没跑几步报错“label out of bounds”或者“image not found”;有的框画出来直接超出图片边界,验证可视化时预测框叠在原图外。

原因:手工标注时有时会把紧挨着目标边缘的框画出图像边界,XML里xmax大于图像宽度,ymax大于高度。转成归一化坐标后,中心点或宽高比值大于1,模型读取时会跨边界。另外YOLO的txt每行只能有一个类别索引加四个数值,AI标注工具导出偶尔会混入空行,也会触发解析错误。

解决:转换脚本里加保护逻辑,先判断坐标是否在图像范围内,越界就截断到边界,然后过滤掉宽度或高度小于10像素的无效小框。下面这段是我常用的裁剪过滤逻辑:

def clamp_to_image(x1, y1, x2, y2, w, h): x1 = max(0, min(x1, w - 1)) y1 = max(0, min(y1, h - 1)) x2 = max(0, min(x2, w - 1)) y2 = max(0, min(y2, h - 1)) if x2 - x1 < 10 or y2 - y1 < 10: return None return x1, y1, x2, y2

这段代码看起来简单,但能过滤掉绝大多数因标注质量差导致的无效框。注意不要只做越界截断而忽略了面积过滤,一个宽度只剩2像素的框放进训练集,会让模型学到无意义特征。

4.2 只有1个类别,为什么模型老把篷布和卡车斗当成晒粮

现象:验证集mAP不低,但视频流里一有盖篷布的卡车、蓝色的塑料布、甚至一片泛白的水泥裂缝,模型就报警。

原因:单类别数据集里大量出现的是“谷物摊铺”这个正样本,而数据集中几乎没有“篷布”“塑料布”“路面破损”这类难负样本。检测器的objectness分支其实是靠正负样本对比来学习背景的,负样本种类不足,模型自然把颜色、纹理上接近的东西都拉高置信度。

解决:收集一批没有打场晒粮但外观接近的图像,放到一个单独目录里,每个图像对应一个空的txt文件,作为负样本加入训练集。注意不是把负样本目录混进正样本目录,而是单独建一个images/neg或把空标注txt放进labels。训练时YOLO会把空txt的图片当背景样本参与损失计算。我在正样本一千多张时加了两百张难负样本,误检率下降非常明显,这比换更大的模型管用得多。

4.3 验证集指标很高,视频里检测框却疯狂闪烁

现象:单张测试图上画框很稳,放到监控视频里,同一片晒粮区域一会儿有框、一会儿没框,框的大小也在跳;连续帧之间没有稳定关联。

原因:这是单帧检测的典型问题。训练时用的imgsz是640,远景的晒粮区域被压缩成很小一块,个别帧的噪声、光照变化会让置信度在阈值上下抖动。模型没有记忆能力,单帧缺陷无法用相邻帧补偿。

解决:部署推理时把imgsz提高到训练值的1.5到2倍,比如训练640就用960或1280推理;同时降低置信度阈值到0.25~0.35,并用帧间IoU匹配做去抖。具体做法在第5章展开。不要指望调一个更高阈值能消除闪烁,阈值调太高会把真实小目标一起丢掉。

4.4 划分数据集后图片与标注对不上号

现象:8:1:1切完,训练时报错“no labels found in images/train”;人工检查发现train里有图,但labels/train里没有同名txt,或xml还在原目录。

原因:多数是划分脚本只复制了图片和XML,忘了复制YOLO的txt;另一个常见原因是文件名大小写不一致,比如图片叫road_001.jpg,xml叫Road_001.xml,Windows下不区分大小写,Linux训练机上就找不到对应标签。

解决:先用一个校验脚本把三个目录的stem集合做diff,列出缺失项。划分脚本里不要用扩展名拼接,统一用Path.stem操作。最后一招:在训练脚本里开启cache和数据检查日志,它会明确打印出有多少张图片找不到标签,比肉眼翻目录快得多。

5. 把模型用起来的两个进阶技巧:更大推理尺度与帧间去抖

5.1 推理时用更大的imgsz,把大目标的稳定度找回来

模型训练用的imgsz是640,不代表推理也必须用640。打场晒粮这种目标占据画面比例很大,但细节纹理又很弱,低分辨率下信噪比差。推理时改用960或1280,能让物体边界更清楚,连续帧之间的检测框也更稳定,代价是推理耗时大约会变成1.3到2倍。如果只有一路视频,GPU又不太老,这个成本通常值得。

大多数推理接口会返回原图坐标,不需要手动做letterbox还原。但如果你把截帧送到自己写的预处理函数里,记住返回的框坐标是在letterbox后图像上的,要按scale和pad反算回原图。这一步错了,框会整体偏移几十像素,而且越靠近边缘偏移越大。

5.2 用帧间IoU做一次简单的轨迹去抖

比加大imgsz更有效的是在后处理加一个短暂记忆。核心思路很简单:当前帧的检测框与上一帧保留的框计算IoU,匹配上就说明是同一个目标;没匹配上的新框先不直接显示,连续出现3帧再输出;匹配上的框在位置更新时做EMA平滑。下面是一个不依赖第三方算法库的伪代码实现,核心方法可以直接适配到推理循环里。

class TrackBox: def __init__(self, box, hits=1): self.box = box self.hits = hits def iou(a, b): # a, b = [x1, y1, x2, y2] xa = max(a[0], b[0]) ya = max(a[1], b[1]) xb = min(a[2], b[2]) yb = min(a[3], b[3]) inter = max(0, xb - xa) * max(0, yb - ya) area_a = (a[2] - a[0]) * (a[3] - a[1]) area_b = (b[2] - b[0]) * (b[3] - b[1]) return inter / (area_a + area_b - inter + 1e-6) class FrameSmoother: def __init__(self, iou_thr=0.4, min_hits=3, alpha=0.6): self.tracks = [] self.iou_thr = iou_thr self.min_hits = min_hits self.alpha = alpha def update(self, boxes): results = [] new_tracks = [] for box in boxes: best_idx = -1 best_iou = self.iou_thr for i, t in enumerate(self.tracks): score = iou(box, t.box) if score > best_iou: best_idx = i if best_idx >= 0: t = self.tracks[best_idx] t.hits += 1 t.box = [int(self.alpha * b + (1 - self.alpha) * t.box[j]) for j, b in enumerate(box)] if t.hits >= self.min_hits: results.append(t.box) new_tracks.append(t) else: new_tracks.append(TrackBox(box, hits=1)) self.tracks = new_tracks return results

上面代码里alpha越大,新一帧位置权重越高,响应越快;alpha越小越平滑,但也越迟钝。min_hits设成3,基本能把单帧噪点过滤掉;如果目标是慢慢铺开的谷物,连续3帧才报警也够快。IoU阈值不要低于0.3,否则一辆车开过晒粮区域时,把车框和晒粮框串在一起,结果会变得更乱。这个逻辑虽然朴素,但在固定摄像头的交通场景下非常有效。

我自己在跑这类单类别大目标检测流程时,最深的教训就是:不要急着换模型,先回去检查负样本和推理尺度。数据集本身质量足够时,模型翻车点往往在转换脚本和推理设置上。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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