ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

起重机数据集YOLO+VOC双格式689张训练实战指南

起重机数据集YOLO+VOC双格式689张训练实战指南 简介面向目标检测入门与工程应用的起重机作业场景数据集专为需要VOC/YOLO双格式标注样本的开发者设计可用于目标检测模型训练、算法对比和基准测试解决标注数据匮乏与格式不统一的问题。压缩包提供清晰的起重机Mobile_crane现场图片并配套XML与TXT两套标注文件分别兼容VOC和YOLO训练管线无需额外转换标签为矩形框共标注764个目标类别单一、图片未增强便于评估模型基本性能。包内文件共2000个以jpg图像、xml及txt标注文件为主整体约34.35MB目录按JPEGImages、Annotations、labels清晰分区方便按图像、标注与标签索引数据减少整理成本。已有170人学习下载适合作为入门练习、课堂案例或私有数据集扩充的基础资源需要说明的是资源仅保证标注准确合理不对任何训练所得模型精度作承诺。1. 起重机数据集YOLOVOC双格式689张够不够训练做工地吊装监测的人容易卡在同一个地方公共数据集里车、人、安全帽一抓一大把塔吊、汽车吊、吊臂货钩这类目标反而难找。好不容易找到一个起重机数据集yolovoc格式-689张.zip解压后又开始纠结yolo和voc两份标注怎么配合要不要自己写转换脚本689张直接训练靠谱吗这份打包的价值是“小而专”yolo的txt喂给训练脚本voc的xml喂给可视化和标注工具两者互为校验省掉最容易翻车的格式转换环节。689张做单类别检测配合预训练权重完全够跑出可用结果适合做吊装区域预警、塔机防碰撞、吊臂检测的从业者也适合刚入门目标检测的人做第一个完整实验。下面从两种格式的对应关系开始把这份数据一步步变成能直接训练的状态。2. 先看懂双格式再动手VOC的XML与YOLO的TXT怎么对齐2.1 VOC那份XML里存了什么一份典型annotations文件拆开看VOC标注的核心是一份xml文件名与图片同名。最外层是annotation节点里面用filename记录原图文件名用size记录这张图的宽度、高度和通道数再用一到多个object节点描述图中每一个目标。每个object里有name表示类别名bndbox里是xmin、ymin、xmax、ymax四个绝对像素坐标。这个schema从VOC2007就开始用至今绝大多数标注工具和框架还认它LabelImg、mmdetection的数据集接口都能直接吃。一份典型的xml长这样annotation foldercrane/folder filenameIMG_2019_07_12_003.jpg/filename size width1920/width height1080/height depth3/depth /size object namecrane/name bndbox xmin412/xmin ymin356/ymin xmax1380/xmax ymax932/ymax /bndbox /object /annotation注意bndbox里是左上角和右下角的像素坐标不是中心点。一个object对应一个目标画面里有两台吊车就有两个object节点。folder字段经常被忽略但很多转换脚本会拿它当类别目录用所以解压后第一步就应该确认folder值与实际类别一致否则后面容易把目录名错当成类别名。还有一点从业者容易忽略size里的width/height是标注时刻的真实分辨率。如果后期有人对图片做了缩放或裁剪却没有同步改xml里的size这份标注就已经悄悄失效了。我对所有外来数据集都会先抽查这个值尤其是爬来的压缩包十个里有两三个会对不上。2.2 YOLO那份TXT的五个数字中心点、宽高与归一化YOLO训练脚本认的不是xml而是每个txt文件里的一行行数字。每行对应一个目标格式固定为五个字段类别id、归一化中心点x、归一化中心点y、归一化宽、归一化高。如果xml里那张1920×1080的图目标的bndbox是412,356,1380,932对应YOLO格式就是0 0.4667 0.5963 0.5042 0.5333换算逻辑是把像素转成相对整图的比例cx (xminxmax)/2 / width (4121380)/2/1920 ≈ 0.4667cy (356932)/2/1080 ≈ 0.5963w (1380-412)/1920 ≈ 0.5042h (932-356)/1080 ≈ 0.5333。所有值都落在0到1之间与图像分辨率解耦这也是YOLO格式能跨分辨率训练的原因。这个格式有一个反直觉的地方人眼几乎无法直接读。打开txt只能看到一排小数根本不知道它对应画面里的哪个目标所以坐标一旦错位排查成本远高于xml。从业者的常规做法是用脚本把txt反算成像素坐标再画框回原图上做视觉校验而不是盯着数字猜。说句实在话我见过太多人把cx、cy当成角点去写转换脚本出来的框全是偏移的还满世界找原因。2.3 双格式对齐关系同名文件、交叉校验脚本与为什么不冲突在一个完整打包里三个文件通常同名不同后缀IMG_2019_07_12_003.jpg、IMG_2019_07_12_003.xml、IMG_2019_07_12_003.txt。689张图对应689个xml和689个txt是理想状态实际分发时经常有缺漏所以解压后第一件事是统计并交叉校验而不是直接开训。txt与xml理论上描述同一批目标可以互相验证行数应等于object数量反算出的像素坐标应等于bndbox值。下面这段脚本就是做这件事的输入一张图的xml与txt输出两者是否一致import xml.etree.ElementTree as ET def load_xml_boxes(xml_path): tree ET.parse(xml_path) root tree.getroot() boxes [] for obj in root.findall(object): b obj.find(bndbox) name obj.find(name).text.strip() xmin float(b.find(xmin).text) ymin float(b.find(ymin).text) xmax float(b.find(xmax).text) ymax float(b.find(ymax).text) boxes.append((name, xmin, ymin, xmax, ymax)) return boxes def load_yolo_boxes(txt_path, img_w, img_h): boxes [] with open(txt_path, r, encodingutf-8) as f: for line in f: parts line.split() if len(parts) ! 5: continue cls_id, cx, cy, w, h int(parts[0]), float(parts[1]), float( parts[2]), float(parts[3]), float(parts[4]) x1 (cx - w / 2) * img_w y1 (cy - h / 2) * img_h x2 (cx w / 2) * img_w y2 (cy h / 2) * img_h boxes.append((cls_id, x1, y1, x2, y2)) return boxesload_xml_boxes负责解析xml里的绝对坐标load_yolo_boxes把txt的归一化值还原成像素坐标两个函数都返回“类别加四个角点”的列表。校验时逐个对比类别和坐标允许1到2像素的浮点误差如果同一张图两边对不上问题基本出在转换脚本的坐标换算上而不是标注本身错了。双格式同时存在还有一个实际好处在LabelImg里打开xml人工复核在ultralytics里直接用txt训练两边互不干扰。很多第三方数据只给一种格式遇到需要另一种格式时自己写转换写错一个分母全盘崩。相比之下BDD100K这类驾驶大集虽然类别多、场景杂但格式相对统一起重机这类专用数据集往往来自零散采集字段命名随意双格式自带冗余的价值反而更高。这份打包等于把后悔药给齐了前提是你要先做一遍上面的交叉校验确认导出时没有弄错坐标顺序。3. 解压与目录重组把zip变成YOLO直接能吃的目录树3.1 拿到zip先做三件事解压、统计、看目录先解压。zip包名是中文解压到英文路径下更省事很多训练容器的locale对中文目录敏感mkdir -p /data/crane unzip 起重机数据集yolovoc格式-689张.zip -d /data/crane中文包名要放进引号里否则bash可能把空格或中文字符当分隔符。解压完成后不要急着开训练脚本先做一次文件清点。统计jpg、xml、txt三类文件数量并核对是否为齐平关系find /data/crane -type f -name *.jpg | wc -l find /data/crane -type f -name *.xml | wc -l find /data/crane -type f -name *.txt | wc -l三条命令分别统计图片、VOC标注、YOLO标注的数量。如果图片数不是689说明解压不完整或者压缩包本身缺图如果xml或txt数量比图片少说明有图没有标注训练时要么忽略要么单独做负样本。zip解压常见的另一个问题是隐藏目录macOS打包会附带__MACOSX目录Linux下解压后会有.DS_Store文件混进glob结果统计数量时要全部排除。文件可能散落在多级子目录里用find递归查找比直接ls稳妥得多。3.2 整理成YOLO标准的images/labels目录树ultralytics和大部分YOLO工程都约定images和labels两个根目录各自再分train/val。手动复制689张图容易错写一个脚本按扩展名归类即可import shutil from pathlib import Path src Path(/data/crane) images_dir Path(/data/crane/images/train) labels_dir Path(/data/crane/labels/train) images_dir.mkdir(parentsTrue, exist_okTrue) labels_dir.mkdir(parentsTrue, exist_okTrue) for img in src.glob(*.jpg): stem img.stem txt src / f{stem}.txt xml src / f{stem}.xml if not txt.exists() or not xml.exists(): print(fskip {stem}: missing label) continue shutil.copy(img, images_dir / img.name) shutil.copy(txt, labels_dir / f{stem}.txt)脚本遍历目录里的jpg按同名找txt和xml三个文件缺一就打印跳过原因。images和labels分开存放是后续训练脚本读取的默认路径约定。xml文件仍然留在原始目录里作为备份不要删后续做格式核查和可视化都要用到它。参数说明只有一个关键点这里用的是copy而不是move。原因是训练过程一旦需要回查原始标注move会让xml和txt位置分散。数据集小的好处是磁盘开销可忽略但“原始文件不动、派生目录另建”这个习惯能让你试错时随时回退。3.3 图片损坏、中文路径与文件名大小写的三个隐性炸弹第一颗炸弹是图片本身损坏。爬来的数据里jpg可能是0字节或只有半个文件头cv2.imread会静默返回NoneYOLO训练时不报错但那一行label没有对应图片。用一个快速预扫描把所有读不了的图挑出来import cv2 from pathlib import Path for img_path in Path(/data/crane/images/train).glob(*.jpg): img cv2.imread(str(img_path)) if img is None: print(broken image:, img_path)cv2.imread在文件不存在或文件损坏时返回None而不是抛异常所以必须显式判断。批量判断后给损坏图片单独建目录隔离不要让训练流程接触到它们。第二颗炸弹是中文路径。Windows压缩时路径可能带中文传到Linux训练机上有的框架能处理、有的直接报编码错误。统一把文件名转成纯英文小写操作不复杂却能省掉一整类与编码相关的怪问题。第三颗炸弹是文件名大小写不一致。IMG_2019_07_12_003.jpg对应IMG_2019_07_12_003.txt看起来整齐但如果图片是.jpg而txt是.JPGWindows和macOS文件系统不区分大小写看不出来训练机上Linux一区分就全对不上。整理脚本里把扩展名统一成小写即可上面的路径遍历已经隐含了这个处理。4. 格式互转与常见问题排查类别、坐标、空标签三个重灾区虽然这份打包同时给了yolo和voc两份标注但实际项目里你迟早要自己写互转可能是标注人员在LabelImg里重新加了框导出只有xml可能是队友只把txt同步到训练机xml留在本地。这里给出一份VOC转YOLO的最小实现再按“现象-原因-解决”列出四个最常见的坑。建议边看边把自己的数据集跑一遍校验不花多少时间却能在训练阶段省下好几个小时的玄学排查。4.1 VOC转YOLO的最小脚本解析xml并写出归一化txt转换脚本的核心是从xml读取每个object再把bndbox绝对坐标换算成归一化的cx、cy、w、himport xml.etree.ElementTree as ET from pathlib import Path classes [crane] def voc_to_yolo(xml_path, out_path): tree ET.parse(xml_path) root tree.getroot() w float(root.find(size).find(width).text) h float(root.find(size).find(height).text) lines [] for obj in root.findall(object): name obj.find(name).text.strip() if name not in classes: print(funknown class {name} in {xml_path.name}) continue cls_id classes.index(name) b obj.find(bndbox) x1 float(b.find(xmin).text) y1 float(b.find(ymin).text) x2 float(b.find(xmax).text) y2 float(b.find(ymax).text) cx (x1 x2) / 2 / w cy (y1 y2) / 2 / h bw (x2 - x1) / w bh (y2 - y1) / h lines.append(f{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) with open(out_path, w, encodingutf-8) as f: f.write(\n.join(lines)) for xml_path in Path(/data/crane/annotations).glob(*.xml): voc_to_yolo(xml_path, Path(/data/crane/labels) / (xml_path.stem .txt))几个参数要解释。classes列表的顺序直接决定cls_id顺序一旦确定就不能改后面训练用的yaml里names必须完全一致。w和h取自xml里的size字段而不是重新读图片算这是为了和标注当时的分辨率保持一致。输出保留6位小数足够覆盖1920宽下的亚像素误差。转换完成后抽几十张图用LabelImg打开xml或者用脚本把txt反算回像素坐标叠在原图上框线贴住目标边缘才算通过。这一步是血泪经验转换脚本不出错不意味着标注不出错视觉复核能同时发现两种类型的问题。4.2 踩坑一空标签文件让背景图变成了隐性负样本现象训练跑起来loss曲线正常但验证集mAP始终上不去抽检发现有些图里明明没有框模型却输出了低置信度的误检框。原因xml里没有任何object的背景图转换脚本生成了0字节或只有换行符的txt。ultralytics默认会把没有标签的图片也加载进dataloader当作负样本参与loss计算。如果背景图占比例大正负样本失衡模型开始偏向“什么都不输出”mAP自然被拖低。解决先清点空txt再决定去留。find /data/crane/labels -type f -name *.txt -size -10c | wc -l这条命令找出小于10字节的txt通常就是空标注或只有空白行的文件。对这份689张的集子把空标签图全部列出确认是否有意保留的负样本如果不是刻意设计就把这些图和txt一并从images/labels目录移除。如果确实需要负样本统一放在一个“background”类里不要混在普通训练集里让框架当漏检惩罚。4.3 踩坑二bndbox越界或宽高为负转换出来的坐标直接越界现象训练日志出现“box out of image bounds”警告或者loss直接变成nan。原因标注工具手滑或者后期批量裁剪图片时没同步更新坐标。常见的脏数据包括xmin小于0、xmax大于图片宽度、xmin大于xmax导致宽为负。更隐蔽的是ymin和ymax写反框在y轴上整个倒过来。解决转换脚本里加合法性断言发现非法框直接报错并输出文件名而不是静默继续。对轻微越界可以clip到合法范围内但宽高为负不能clip必须定位到原始xml人工修。if x1 0 or y1 0 or x2 w or y2 h: print(fbox out of bounds: {xml_path.name}, {x1},{y1},{x2},{y2}) if x2 x1 or y2 y1: print(finvalid box: {xml_path.name})这段检查放在坐标换算之前越界和反向框属于数据错误标准做法是打印出来单独处理而不是自动修掉。因为一旦自动修根本不知道原标注到底错在哪后面的视觉复核也发现不了。4.4 踩坑三类别名字大小写不一致类表顺序完全错乱现象训练完的模型把吊车识别成另一个类别或者验证时报类别索引超出范围。原因VOC的name字段是字符串打包方可能有的写“crane”、有的写“Crane”、有的写“吊车”而YOLO用整数类别idid与类表顺序强绑定。如果你在类表里只放了一个crane其他写法全被当成unknown class跳过甚至报错。解决转换前先扫描全部xml统计所有name的原始写法集合再做一次字符串归一化names set() for xml_path in Path(/data/crane/annotations).glob(*.xml): root ET.parse(xml_path).getroot() for obj in root.findall(object): names.add(obj.find(name).text.strip().lower()) print(sorted(names))先跑一遍这个统计再据此维护classes列表比对着目录名猜类表靠谱得多。顺便说一句很多人总问coco的80类怎么读进自己的yolo工程核心就是class id映射coco.names第0行是person你的数据集第0行是crane仅此而已自己的类别一定要从0开始连续编号。4.5 踩坑四图像尺寸不一致归一化坐标“看起来没毛病”实则全错现象同一批数据里既有1080p大图又有720p小图训练时resize到640模型在验证集上框的位置往左上偏移置信度也忽高忽低。原因txt里的坐标是按各自原图尺寸归一化的比例值本身没错错的是有人在中间环节按新分辨率重新算了一次标签或者数据加载器读到的尺寸与标注时不一致。比如把1280宽的图先缩成960再用960做分母除坐标所有cx、cy都偏了。解决一切以xml里的size字段为准不要从图片EXIF或文件名猜分辨率。确定统一训练分辨率之后用校验脚本把txt反算回像素坐标并标注在图上人工抽查50张框边线与目标轮廓重合度超过一个像素就是有偏差。转换后的txt一旦确认无误就以txt为唯一训练源xml只留作回查。5. 训练前的数据校验与集划分689张怎么切才不翻车格式与目录理顺后下一件事不是把数据直接扔给训练命令而是先回答三个问题图像分辨率分布是不是一致目标尺寸是偏大还是偏小train/val怎么切才能防止相似帧泄漏这三个问题没想清楚后面所有训练参数都是盲调。5.1 图像尺寸与宽高比分布统一resize之前先看统计起重机的作业场景里摄像机分辨率跨度很大固定机位可能是1080p巡检用的运动相机可能是720p甚至有的图是从视频里截出来的宽高比千奇百怪。统一resize到640×640会破坏细长目标的比例所以先跑一个分布统计import cv2 from pathlib import Path sizes [] for img_path in Path(/data/crane/images/train).glob(*.jpg): img cv2.imread(str(img_path)) if img is None: continue h, w img.shape[:2] sizes.append((w, h, w / h)) print(total:, len(sizes)) print(aspect ratio range:, min(s[2] for s in sizes), -, max(s[2] for s in sizes))宽高比接近1的是常规场景明显偏向0.5或2.0的要么是裁切过的特写要么是超宽全景。对于后者训练时letterbox会把两侧大量填充黑边等于压缩了目标尺寸。常见做法是训练输入用1280或1600如果显存实在紧张就按宽高比分成两组分别训练而不是硬压成方形。5.2 目标尺寸与数量先看小目标占比再决定模型和输入尺寸统计框的像素面积占整图比例是判断任务难度最直接的办法。吊车在远景里可能只占几十个像素这时候yolov8s在640输入下几乎学不到细节。写个统计脚本把每个目标的宽高、面积占比、单图目标数都打出来import xml.etree.ElementTree as ET from pathlib import Path areas [] per_image_counts [] for xml_path in Path(/data/crane/annotations).glob(*.xml): root ET.parse(xml_path).getroot() w float(root.find(size).find(width).text) h float(root.find(size).find(height).text) count 0 for obj in root.findall(object): b obj.find(bndbox) bw float(b.find(xmax).text) - float(b.find(xmin).text) bh float(b.find(ymax).text) - float(b.find(ymin).text) areas.append(bw * bh / (w * h)) count 1 per_image_counts.append(count) print(avg box area ratio:, sum(areas) / len(areas)) print(max obj per image:, max(per_image_counts))面积占比低于0.01的属于小目标占比高于0.3的属于特写大目标。如果小目标占比超过四分之一优先把imgsz提到1280甚至1600或者换带P2层的模型如果全是特写大目标反而要担心模型只学了“位置”没学“外观”此时做强数据增强更有用。顺带提醒这份打包是检测框将来如果升级到yolo实例分割689张检测框不能直接当分割标注用需要重新补掩码别指望一份标注通吃。5.3 按场景划分而不是随机划分避免同场景帧泄漏进验证集随机划分在通用数据集上问题不大但在工地这种连续采集的数据上一拆就出问题。同一个机位、同一天拍的几十张图高度相似随机切分很可能让验证集里出现与训练集几乎相同的内容mAP虚高一到现场换角度就崩。按文件名前缀分组再划分是更稳妥的做法。起重机的图片常用日期或拍摄批次做前缀比如IMG_2019_07_12_003.jpg里的20190712就是场景分组标志import random from collections import defaultdict from pathlib import Path groups defaultdict(list) for img in Path(/data/crane/images/train).glob(*.jpg): scene img.stem.split(_)[1] groups[scene].append(img) scene_names list(groups.keys()) random.seed(42) random.shuffle(scene_names) val_scenes set(scene_names[:int(len(scene_names) * 0.15)]) for scene, imgs in groups.items(): if scene in val_scenes: print(val:, len(imgs)) else: print(train:, len(imgs))按场景id整组切分保证同一个场景的连续帧只进一边val的mAP才有参考意义。这段脚本里的split(_)[1]取值依赖文件名里的日期字段实际使用时改成你包里稳定的文件名分隔规律即可。如果文件名里没有明确的时间或批次字段就用拍摄设备ID、地点前缀这类不会变的字符串来分组实在分不出来至少按目录来切。5.4 一份能直接跑的yolov8训练配置与启动命令目录结构、校验脚本、划分脚本都跑完终于到写配置的时候。数据yaml只需要两个关键信息类别数和类别名。path: /data/crane train: images/train val: images/train # 先用训练集做占位下面会改成真正的val目录 names: 0: cranetrain和val先指向同一个目录等5.3的划分脚本把文件移到真正的val目录再改回来。这样新手即使第一步跑不通也不会误删数据。启动训练用这条命令yolo detect train \ data/data/crane/crane.yaml \ modelyolov8s.pt \ imgsz1280 \ batch8 \ epochs200 \ patience20模型用yolov8s预训练权重而不是从头训练689张从零开始必过拟合迁移学习是这条路的唯一靠谱选项。imgsz按5.2的统计结果定小目标多就1280显存不够降到960或640。batch取8是保守值后续看显存占用逐步往上调。epochs设200但patience20意思是20个epoch验证集指标不涨就早停给足训练机会又不至于死等。loss曲线主要看box_loss和cls_loss两个分量的下降节奏box一直不降说明回归难cls一直不降说明类别难分两个都不降再回头查数据而不是换更大的模型。另外如果类别间样本数悬殊比如crane只有几十张而背景图占几百张先做类别重采样或者删掉过量背景图再动训练参数类别失衡时调什么loss都救不回来。6. 训练后的验证技巧mAP之外看PR曲线和实际场景视频mAP50和mAP50-95能说明训练收敛情况但不能告诉你模型在真实工地长什么样。689张数据的模型最怕的是“验证集好看现场翻车”所以训练完先做两件事。第一件是看PR曲线和F1曲线而不是只盯mAP。训练输出目录里会生成PR_curve.png和F1_curve.pngPR曲线横轴recall、纵轴precision曲线的拐点就是置信度阈值甜点区。单类别小数据集里如果曲线明显内凹说明模型在低置信度区间大量误检这时候该回看第4章里的空标签问题而不是急着调阈值。第二件是拿几段现场视频批量推理把所有检测结果保存成带框小图做人工快速质检from ultralytics import YOLO import cv2 model YOLO(/data/crane/runs/detect/train/weights/best.pt) cap cv2.VideoCapture(/data/crane/live_clip.mp4) while cap.isOpened(): ret, frame cap.read() if not ret: break results model(frame, conf0.35, imgsz1280, verboseFalse)[0] for box in results.boxes: x1, y1, x2, y2 map(int, box.xyxy[0].tolist()) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 0, 255), 2) cv2.imwrite(/data/crane/output_frame.jpg, frame) break这段只截了一帧做演示实际批量处理把循环跑完整条视频即可。conf0.35在吊装场景算偏严误检多就往上调到0.5漏检明显就降到0.25最终阈值以PR曲线拐点为准。对起重机的细长吊臂目标如果IoU评估一直波动可以试试NWD这类基于归一化距离的指标替换IoU做验证它对小框的位置误差更宽容能更真实反映吊臂漏检情况。另外吊臂是有角度姿态的细长目标水平框会包裹大量背景要进一步提升精度后续可以考虑旋转框检测方案mmrotate这类框架能接回原标注但旋转框格式和这里voc的水平框并不等价需要重新标注或者用半自动标注辅助。说个我自己踩过的坑第一次跑类似这种起重机数据集时我偷懒没做空标签过滤val里混进一批背景图mAP看起来不算差真放到现场视频里吊臂根本框不稳。后来把校验脚本固化成了数据管线的第一步才真正把指标和现场体验对上。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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