简介:面向目标检测初学者与算法工程师的楼梯识别数据集,图片拍摄清晰、场景贴近日常楼道环境,可用于训练楼梯检测模型或验证YOLO/VOC系列算法效果,解决楼梯场景样本不足问题。压缩包共2000个文件,其中jpg原图1043张,配套xml与txt标注文件各1043个,分别对应VOC格式和YOLO格式,整体封装为仅9.49MB的zip包,目录按JPEGImages、Annotations、labels清晰划分,便于直接读取与转换。所有图片均未增强,标签类别为单一staircase,共1224个矩形框,标注准确规范;资源内另附数据集信息说明txt,可快速了解文件组织与标注规范,方便训练前核对标注信息。目前已有88人学习下载,适合需要标准格式数据集开展模型训练、标注校验或目标检测入门实践的学习者使用,可省去自行采集与标注的时间,直接聚焦算法调参与效果优化。
1. 楼梯目标检测数据集:1043 张图能直接喂给训练脚本
做目标检测的同行都有体会:公开数据集里人、车、猫狗很多,楼梯这种室内结构化目标反而难找。这份楼梯数据集共 1043 张图片,同时给 YOLO 和 VOC 两套标注,解压后直接能喂训练脚本。
它解决的痛点很实际——楼梯检测在建筑巡检、机器人导航、安防监控里都是刚需,但自采数据加标注一千多张图,画框就要两三天,还不算返工。适合正在跑 YOLOv8/YOLOv5 训练、需要现成数据的算法工程师,以及做室内机器人视觉验证的学生。
下面按我拆包的固定套路,把目录解构、标注校验、训练参数、排障记录和二次迭代依次讲完。每步都给能直接跑的脚本,照着执行就能复现整条链路,也能绕开我踩过的几个明显坑。
2. 先摸清压缩包:YOLO 与 VOC 两套标注的目录结构
2.1 VOC 标注:XML 里藏着图尺寸与框的绝对坐标
VOC 是 PASCAL VOC 体系留下的 XML 标注格式,特点是「每个物体一个 object 节点」,框坐标用绝对像素值表示,不做归一化。对检测任务来说,读 XML 能拿到三类关键信息:图片宽高、目标类别名、bndbox 的四个像素坐标。LabelImg 这类标注工具导出的就是这个结构,所以很多老检测框架(原版 Faster R-CNN 的 voc 数据类)直接吃 XML。
<annotation> <folder>images</folder> <filename>stair_0001.jpg</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>stair</name> <bndbox> <xmin>240</xmin> <ymin>180</ymin> <xmax>980</xmax> <ymax>690</ymax> </bndbox> <truncated>0</truncated> <difficult>0</difficult> </object> </annotation>这段 XML 是这类数据集里的典型样本。size 节点里的 width 和 height 是后续一切换算的基准,bndbox 的四个值分别代表框左上角和右下角的 x、y 像素坐标。truncated 表示目标是否被图边缘截断,difficult 表示是否难例,这两个字段多数训练脚本不读,但标注工具会保留并在下次编辑时回写。
实际使用中你会发现,XML 存在的意义更多是「给人看」和「给标注工具续用」。比如你想在 LabelImg 里打开某张图补框,它认的就是 XML;想把标注转成 COCO 的 json,第一步也是从 XML 读坐标。后面写转换脚本时,解析 XML 比解析 txt 更稳,因为 XML 里带着图宽高,不容易算错分母。
2.2 YOLO 标注:一行五个数,坐标全部按图归一化
YOLO 的标注格式极简,每行五个数字:类别 id、中心点 x、中心点 y、框宽、框高,全部除以图片宽高做归一化。以这个楼梯数据集为例,如果 classes.txt 里只有 stair 一个类,它的 id 就是 0,对应标注文件里的一行长这样:
0 0.4766 0.6042 0.5781 0.7083换算关系是固定的:cx = (xmin + xmax) / 2 / width,w = (xmax - xmin) / width,y 方向同理用 height 做分母。反过来,把 YOLO 坐标还原成像素坐标,就是 xmin = (cx - w/2) × width。这两组公式是整个数据集最常用的工具,后面章节的校验脚本全靠它。
| 维度 | VOC XML | YOLO txt |
|---|---|---|
| 坐标形式 | 绝对像素 xmin/ymin/xmax/ymax | 归一化浮点 cx/cy/w/h |
| 目标表示 | 每个目标一个 object 节点 | 每行五个数字 |
| 可读性 | 人能读、工具能改 | 简单但容易写错 |
| 典型消费方 | Faster R-CNN、SSD 的 voc 分支 | YOLO 全系 |
为什么 YOLO 要归一化?因为训练时图片会被随机缩放、pad 成 640×640 的输入,如果标注是绝对像素,缩放后还要重新映射;归一化之后,只要训练脚本做相同的 letterbox 处理,标注就不用动。数据集同时给两套格式,就是为了兼容两条技术路线:跑 YOLO 系直接用 txt,跑检测头更重的框架直接上 XML。
需要提醒的是,txt 里的类别 id 必须和 data.yaml 的 names 顺序严格对应。单类别数据集表面上安全,但你后续如果要加一类「扶手」或者「台阶」,新类必须 append 在末尾,插在中间会把所有历史标注全部错位。我一般会在项目里专门留一个 classes.txt,每次动类别顺序都先改它,再批量重写 txt。
2.3 目录与命名:先跑一遍 tree 再动手,别急着训练
这类双格式数据集最常见的排布是 images / Annotations / labels 三个平级目录,图片统一 jpg,XML 和 txt 跟图片同名不同后缀。解压之后我习惯先跑一次目录树,确认没有嵌套错位:
unzip 楼梯数据集1043张YOLO+VOC.zip -d stairs_dataset tree stairs_dataset -L 2常见输出大概是:
stairs_dataset/ ├── images/ │ ├── stair_0001.jpg │ ├── stair_0002.jpg │ └── ...(共 1043 张) ├── Annotations/ │ ├── stair_0001.xml │ └── ... └── labels/ ├── stair_0001.txt └── ...命名规则是三件套同名:stair_0001.jpg、stair_0001.xml、stair_0001.txt。这个规则直接决定了后面校验脚本怎么写——按 stem 配对,而不是按文件名乱序匹配。有些打包者习惯把图片直接散在一个目录里,有些会预先分成 train/val 两个子目录,两种都正常;我遇到预分目录时会先把 train/val 合并,自己用固定随机种子重新划分,保证实验可复现。
解压后第一件事千万别直接开训练。先把图片总数、标注文件总数、XML 和 txt 是否齐平这三件事查清楚。我在别的数据集上吃过亏,一千多张图里有二十来张 xml 和 txt 数量对不上,训练时 loss 照常下降,但 val 结果一直不对,查了半下午才发现是配对错位。下一章给的校验脚本就是干这个的。
3. 训练前先校验:用三段脚本把 1043 张标注统查一遍
3.1 统计脚本:类别、框数与图片尺寸一次摸清
拿到新数据集先别急着划 train/val。第一步是全局统计,搞清楚一共多少个框、每类多少目标、图片分辨率分布如何。这些数字直接决定后面的超参数——比如楼梯目标普遍偏大还是偏小,决定 imgsz 选 640 还是 1280。下面这段脚本解析全部 XML,输出汇总信息:
import os import xml.etree.ElementTree as ET ann_dir = "Annotations" stats = {"total_images": 0, "total_boxes": 0, "classes": {}} size_hist = [] for xml_name in sorted(os.listdir(ann_dir)): if not xml_name.endswith(".xml"): continue tree = ET.parse(os.path.join(ann_dir, xml_name)) root = tree.getroot() w = int(root.find("size/width").text) h = int(root.find("size/height").text) size_hist.append((w, h)) stats["total_images"] += 1 for obj in root.findall("object"): cls = obj.find("name").text stats["classes"][cls] = stats["classes"].get(cls, 0) + 1 stats["total_boxes"] += 1 print("图片数:", stats["total_images"]) print("总框数:", stats["total_boxes"]) print("类别分布:", stats["classes"]) print("分辨率范围:", min(size_hist), max(size_hist))脚本逻辑很简单:遍历 Annotations 目录下所有 XML,逐个解析 size 和 object 节点,累加类别计数和分辨率列表。四个输出项里,类别分布决定 data.yaml 的 nc 和 names,分辨率范围用来判断训练时的 imgsz——如果多数图在 1920×1080 附近,而楼梯目标只占画面五分之一,那 640 的输入尺寸会让目标缩到十几个像素,后面必然漏检。
有个细节:分辨率用 min/max 打出来只能看大区间,我一般会再按宽高比分桶统计一次,看看是否存在竖图。楼梯场景里手机竖拍的图很常见,如果竖图比例超过两成,训练时就要在增强里显式加旋转,否则模型对竖构图泛化很差。
3.2 越界检查:txt 坐标不在 [0,1] 内的一律标红
YOLO 训练对标注的要求是严格归一化。坐标大于 1、小于 0、w 或 h 为 0,这些标注在训练时会被直接过滤或导致 loss 异常。更隐蔽的是浮点精度问题:如果 XML 转 txt 时忘了除以 width,会出现 cx 在 0.5 附近但 w 接近 1.5 的情况,loss 能降但框全歪。检查脚本:
import os label_dir = "labels" bad = 0 for txt_name in sorted(os.listdir(label_dir)): if not txt_name.endswith(".txt"): continue path = os.path.join(label_dir, txt_name) with open(path) as f: for line in f: parts = line.strip().split() if len(parts) != 5: print(f"{txt_name}: 列数不为5 -> {line.strip()}") bad += 1 continue cls, cx, cy, w, h = parts cx, cy, w, h = map(float, (cx, cy, w, h)) if not (0 <= cx <= 1 and 0 <= cy <= 1 and 0 < w <= 1 and 0 < h <= 1): print(f"{txt_name}: 越界 -> {line.strip()}") bad += 1 print("异常行数:", bad)这段脚本把每个 txt 的每一行拆成五个字段,先检查列数,再检查数值范围。正常标注里 cx、cy 应该在 0 到 1 之间,w、h 应该在 0 到 1 之间且大于 0;如果出现负数或大于 1 的值,基本可以断定是转换脚本除错了分母。越界行不需要手工改,用下面这个公式批量重算更省事:
# 用 XML 里的真实宽高把越界行重新归一化 from PIL import Image import os def fix_txt(image_path, txt_path): w, h = Image.open(image_path).size new_lines = [] with open(txt_path) as f: for line in f: cls, cx, cy, bw, bh = line.strip().split() cx, cy, bw, bh = map(float, (cx, cy, bw, bh)) # 先按原标注反算像素坐标 xmin = (cx - bw / 2) * w ymin = (cy - bh / 2) * h xmax = (cx + bw / 2) * w ymax = (cy + bh / 2) * h # 再用真实图宽高重新归一化 new_cx = (xmin + xmax) / 2 / w new_cy = (ymin + ymax) / 2 / h new_w = (xmax - xmin) / w new_h = (ymax - ymin) / h new_lines.append(f"{cls} {new_cx:.6f} {new_cy:.6f} {new_w:.6f} {new_h:.6f}") with open(txt_path, "w") as f: f.write("\n".join(new_lines))这个修法有个前提:像素坐标本身是对的,只是归一化分母错了。如果原始 XML 的 bndbox 就有问题,修 txt 是治标不治本,得回头找标注源头重新校正。
3.3 配对检查:图、XML、txt 三者必须同名对齐
第三个检查是配对,也是我血泪经验最集中的一步。训练脚本按图片名找同名 txt,如果 txt 缺了,这张图会被当成无目标背景图参与训练,模型学到的全是「这张图没有楼梯」的错误信号;如果 txt 多了而图不存在,训练直接报文件找不到。检查脚本:
import os img_dir, ann_dir, label_dir = "images", "Annotations", "labels" def stems(d): # 取文件名主干,去掉后缀 return {os.path.splitext(f)[0] for f in os.listdir(d) if not f.startswith(".")} imgs, anns, labels = stems(img_dir), stems(ann_dir), stems(label_dir) print("图片:", len(imgs), "XML:", len(anns), "txt:", len(labels)) print("缺 XML:", len(imgs - anns), "缺 txt:", len(imgs - labels)) print("多余 XML:", len(anns - imgs), "多余 txt:", len(labels - imgs))输出四组差值,分别对应「图没有标注」「标注没有图」两类问题。对 1043 张的数据集,理想输出是四个 0。差几个的话手工补;差太多就得怀疑压缩包在传输时丢文件,重新解压再查。
这三个检查都跑完且全绿,这个数据集才算真正可用。我自己的习惯是写成一个 check_dataset.py 一次性跑完,任何一条异常输出都拦在训练之前。后面训练翻车的第一排查项,永远是回头跑这三段脚本,而不是改学习率——先怀疑数据,再怀疑模型,这是顺序问题,也是纪律问题。
4. 跑通 YOLOv8 训练:从数据划分到权重导出的完整参数表
4.1 数据划分:单类目标也要做分层抽样
校验完就该划分数据集。常见比例是 train:val:test = 8:1:1,但划分方式有讲究。直接 random.shuffle 虽然简单,却可能让 val 集里某一类目标数量极端——单类楼梯数据没有类别错位问题,但存在场景分布问题:如果某栋楼的楼梯间照片恰好全进 val,训练集就缺了这种拍照角度,val 分数会虚低。
import os, random, shutil random.seed(42) img_dir = "images" imgs = [f for f in os.listdir(img_dir) if f.endswith(".jpg")] random.shuffle(imgs) n = len(imgs) train, val, test = imgs[:int(n*0.8)], imgs[int(n*0.8):int(n*0.9)], imgs[int(n*0.9):] def move(split, dest): # 图片和同名 txt 一起复制到目标 split os.makedirs(f"data/{dest}/images", exist_ok=True) os.makedirs(f"data/{dest}/labels", exist_ok=True) for f in split: shutil.copy(f"{img_dir}/{f}", f"data/{dest}/images/{f}") stem = os.path.splitext(f)[0] shutil.copy(f"labels/{stem}.txt", f"data/{dest}/labels/{stem}.txt") move(train, "train") move(val, "val") move(test, "test") print(len(train), len(val), len(test))固定随机种子 42 保证每次划分结果一致,这样两次实验的对比不会因为数据不同而失真。复制而不是移动文件,是为了保留原始目录做对照——万一划分后某个 split 里缺了文件,还能从原始目录快速比对。1043 张按 8:1:1 切出来大约 834/104/105 张,val 和 test 各一百张出头,对单类检测够用;如果追求更稳的指标,可以把 test 合并进 val,只保留 train/val 两个 split,val 占比提到两成。
4.2 data.yaml:路径、nc 与 names 必须逐一对应
YOLOv8 用 data.yaml 描述数据集。路径可以写绝对路径也可以写相对路径,我一般写相对路径并保证启动命令的当前目录在项目根——这样换机器不踩路径坑。
path: ./data train: train/images val: val/images test: test/images nc: 1 names: 0: stairpath 指向数据根目录,train/val/test 填相对于 path 的子目录。nc 是类别总数,names 的索引必须跟 labels 里 txt 第一列的数字严格一致。这张表里类别叫 stair 还是 staircase 并不影响训练,影响的是后续部署时显示名,保持一致即可。
改 yaml 时最容易出错的地方是方向:train 指向了 images 还是 labels。YOLOv8 会在 train 目录下自动找同名 labels 目录,所以 yaml 里写 train/images,它会自己找 train/labels;如果你写反成 train/labels,训练会报找不到图片。我见过同事在这里卡了半小时,报错信息是数据集找不到,一查是路径配反了。
4.3 训练命令:模型体积、imgsz 与 batch 的取舍
装好 ultralytics 后,训练命令是单行的事:
pip install ultralytics yolo detect train \ data=data.yaml \ model=yolov8s.pt \ epochs=100 \ imgsz=640 \ batch=16 \ patience=20model 参数填的是预训练权重,yolov8n 是 nano 版体积最小,s 是小版,m 和 l 依次变大。对 1043 张的楼梯数据,我一般从 yolov8s 起步:n 在室内小目标场景下容易欠拟合,l 又需要更多数据去喂,s 是性价比平衡点。epochs=100 对单类数据偏多,patience=20 会让 val 指标连续 20 轮不涨就早停,实际通常五六十轮就收敛。
| 参数 | 推荐值 | 调整方向 |
|---|---|---|
| model | yolov8s.pt | 显存小换 n,精度优先换 m |
| imgsz | 640 | 小目标多时提到 960/1280,显存翻倍 |
| batch | 16 | 8G 显存减到 8,OOM 先降这个 |
| epochs | 100 | 配合 patience 早停,不用硬跑满 |
| patience | 20 | 越小越早停,过大容易过拟合后还在跑 |
imgsz=640 是默认值,但前面统计分辨率时如果发现楼梯目标占比小,可以提到 960 或 1280,代价是显存和训练时间翻倍。batch=16 取决于显存,8G 卡跑 yolov8s 建议 16,不够就减半,不要开梯度累积硬撑——排查问题时少一个变量比省几分钟更有价值。
训练日志里重点看两行:loss 是否在几十轮内降到 0.05 以下,val 的 mAP50 是否在 0.9 以上。YOLOv8 的损失函数默认是 CIOU 加分类损失,日志里的 box_loss 和 cls_loss 分开显示;如果 box_loss 降了但 cls_loss 不动,多半是类别 id 错位,回头检查 yaml。
4.4 验证与导出:权重怎么从实验落到部署
训练结束后,val 结果自动输出在 runs/detect/train 目录下,包括混淆矩阵、PR 曲线和各类的 mAP。单类数据看混淆矩阵最简单:横轴真实、纵轴预测,对角线的 1.0 就是全部命中。
yolo detect val \ model=runs/detect/train/weights/best.pt \ data=data.yamlbest.pt 是 val 指标最好的权重,last.pt 是最后一轮的。部署用 best.pt,导出 ONNX 也用它。导出命令:
yolo export model=runs/detect/train/weights/best.pt format=onnx imgsz=640导出后跑一次推理,拿一张 val 里没见过的楼梯照片验证,确认框的位置和类别名都正常,再考虑接 TensorRT 或 OpenVINO。到这里,从解压到可用权重的主链路已经走通,剩下的是现场适配和迭代。
5. 避坑与常见问题:标注错位、格式不同步与训练发散排查
这一章是我在多个场地数据集上反复翻车后攒下的记录,每一条都按「现象 → 原因 → 解决」写,直接对照自己的日志看就行。
5.1 现象:loss 正常下降但 val 的 mAP 全为 0
训练日志里 train loss 一路降到 0.02,val 的 mAP50 始终是 0,没有任何波动。这个现象在双格式数据集上最容易出现——图片和标注来自不同打包环节,某个 txt 里的框坐标整体出错。
原因分两类:一是文件名错位,脚本按索引而不是按 stem 配对时,标注张冠李戴;二是 txt 与 XML 不同步,比如有人改了 XML 补框,但训练读的是 txt。解决方法是先跑第三章的配对脚本确认三件套一致,再抽几张图画框肉眼确认:
import cv2 img_path = "images/stair_0001.jpg" txt_path = "labels/stair_0001.txt" img = cv2.imread(img_path) h, w = img.shape[:2] with open(txt_path) as f: cls, cx, cy, bw, bh = f.readline().split() cx, cy, bw, bh = map(float, (cx, cy, bw, bh)) # YOLO 归一化坐标还原成像素框 xmin = int((cx - bw / 2) * w) ymin = int((cy - bh / 2) * h) xmax = int((cx + bw / 2) * w) ymax = int((cy + bh / 2) * h) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.imwrite("check.jpg", img)这段代码把归一化坐标还原成像素框并画到图上,保存成 check.jpg 人工看。抽样三到五张就够了;如果抽查全部错位,直接回到转换脚本找 bug,不要人工修标注。
5.2 现象:mAP 很高但现场实拍漏检严重
模型在 val 上 mAP50 有 0.95,拿到现场用手机拍一段楼梯视频,发现很多角度完全检测不到。这个坑跟数据集本身质量无关,是采样偏差:数据集中楼梯多为正面标准视角,而现场视频大量出现俯拍、仰拍、逆光和部分遮挡。
解决分两步。第一步在训练增强里加大随机性和角度扰动,imgsz 保持 640,mosaic、mixup、上下翻转都打开;第二步收集现场失败帧做难例回灌,这是第六章的主线。注意不要一上来就换大模型,场景不匹配时换 l 版只会让过拟合更严重。
5.3 现象:改了 XML 却对训练结果毫无影响
有人用 LabelImg 打开 XML 把漏检的楼梯框补上,重新训练发现指标纹丝不动。原因很直接:YOLOv8 训练读的是 labels/.txt,不是 Annotations/.xml。XML 只是给人看和给旧框架用的副本,改了它不重写 txt,训练数据就等于没变。
解决方法是改标注时统一入口:要么全程用 txt 手工改,要么改完 XML 后跑一遍转换脚本把 XML 全量同步成 txt。我自己的做法是在资源目录里放一个 XML 转 YOLO 的脚本,每次标注迭代结束强制跑一遍,确保两套格式永远一致。
5.4 现象:Linux 下解压中文名 zip 出现乱码
这个资源压缩包名是中文,在 Windows 下双击解压没问题,放到 Linux 服务器上用 unzip 解压,目录名变成乱码,脚本里写死的中文路径全部失效。
原因:zip 包内文件名编码是 GBK 或 UTF-8,Linux 的 unzip 默认按系统 locale 解码,中文环境下容易错位。解决方法是显式指定编码:
unzip -O gbk 楼梯数据集1043张YOLO+VOC.zip -d stairs_dataset如果 -O 参数不被当前 unzip 版本支持,用 7z 代替,7z 对中文名 zip 的兼容性更好。解压后第一时间执行第二章的 tree 命令确认目录名正常,再往下走流程。
5.5 现象:训练 loss 发散,权重变成 NaN
loss 在前几轮直接冲到几十,然后出现 NaN,训练中断。楼梯数据集本身是常规图像,不太可能是数据问题,多半是超参数:学习率默认配大 batch 时偶发,或 imgsz 设得过大把显存压爆,OOM 后显存里的脏数据写回权重。
解决顺序是先降 batch 到 8 或 4,确认不 OOM;再检查是否开了混合精度,关了 amp 再试;最后把 model 换成 yolov8n 做两轮冒烟训练,能跑通就说明是资源或超参数问题,不是数据问题。冒烟训练用 20 张图、10 个 epoch,全程五分钟,是排查训练异常最快的手段。
6. 进阶用法:难例挖掘与 ONNX 导出后的部署验证
6.1 用训练好的 best.pt 回灌现场图
训练收敛后,把现场拍的、网上找的、以及 val 里表现差的图全部丢给模型跑一遍预测:
yolo predict model=runs/detect/train/weights/best.pt \ source=hard_samples/ \ conf=0.35 save_txt=Trueconf 阈值放低到 0.35,目的是让模型把拿不准的框也吐出来。预测结果里置信度在 0.35 到 0.6 之间的框,就是最好的追加标注素材——把它转回 VOC 格式导入标注工具,人工确认后并入训练集,再来一轮训练。如此迭代两轮,现场漏检率通常能明显下降。注意回灌图不要和原训练集重复,重复样本会让 val 分数虚高而不提升真实泛化。
6.2 部署验证不要只看一张图
ONNX 导出后,我习惯写一个最朴素的验证脚本:读一张没参与训练的图,跑推理,把输出框画出来,用和训练时相同的 letterbox 参数对齐坐标。很多部署翻车都出在坐标对齐上——训练时图片被 letterbox 成 640×640,推理时如果忘记做同样变换,框的位置会整体偏移。
从那以后,我每次拿到新数据集都强制走一遍「解压 → 校验 → 划分 → 小模型冒烟 → 正式训练 → 现场抽测」的固定流程,校验脚本三件套永远放在第一位。数据集的坑从来不在训练本身,而在你信任标注的那一刻。希望帮到你。
本文还有配套的精品资源,点击获取