简介:目标检测在低光照场景中常因数据集质量不足而性能骤降,夜间车辆行人检测更是依赖标注规范与数据划分的合理性。YOLO格式以纯文本存储归一化边框坐标,通过classes.txt与data.yaml完成类别映射,其目录结构和标签定义直接影响模型的收敛与mAP表现。理解类别边界、统一遮挡目标标注规则,并利用可视化脚本校验数据质量,是提升训练效率的关键前置步骤。在夜间安防、交通监控等场景下,合理配置YOLOv8增强参数与训练策略,可有效应对小目标、过曝与类别不平衡问题。围绕YOLO数据集的准备、清洗与验证,本文提供了一套从数据到训练的可复用实践方法,帮助开发者降低试错成本。
1. 夜间车辆行人检测的 4 类 YOLO 数据集:能省掉你最头疼的标注环节
晚上跑车辆行人检测,最容易翻车的不是模型结构,而是数据集本身。夜间图像对比度低、运动模糊多、车灯过曝,拿白天数据硬训,mAP 可能掉 10 个点以上。这版 4 类 YOLO 数据集把夜间车辆、行人按固定类别划分好,train/val/test 目录、class 文件、数据可视化脚本一次配齐,拿到就能在 YOLOv8 里开训,省掉最耗时的一张张标注和划分环节。适合两类人:做夜间安防与交通监控、想快速验证流程的从业者,以及手头只有白天或低光照素材、不知道怎么下手的初学者。下面按“先看懂类别定义,再开训,再用可视化脚本查质量”的顺序展开,最后给出夜训场景里最常见的几个坑。
2. 先看懂 4 类目标和 class 文件:类别定义、txt 标签与 data.yaml 的关系
2.1 这 4 类到底怎么标:car、person、motorcycle、bus 的边界
拿到数据集第一步不是开训,而是打开 class 文件确认类别。常见做法是把 4 类定成:car、person、motorcycle、bus。car 指小轿车和 SUV;bus 指公交车和旅游大巴;person 指行人;motorcycle 指电动两轮车和摩托车。这个划分对应的是夜间路口和城市道路监控里最常出现的四类目标。类名看着简单,边界才是问题。
我一般按一套固定规则来确认标注口径:骑在摩托车上的人,只标 motorcycle,不标 person;坐在车里的人,只标 car,不标 person;公交车统一进 bus,哪怕是小型接驳巴士。这个口径直接影响后面混淆矩阵的表现。如果你拿到数据集后先自己看一眼 class 文件,再看几张 labels 的 txt,就能判断它的标注规范偏严还是偏松,这比直接跑训练重要得多。
另一个需要确认的点是:这 4 类里有没有“车辆”的统称。有的数据集会把 car 和 bus 合并成 vehicle 一类,再把行人和两轮车分开,这样也是 4 类。所以不要想当然,任何一份别人划分好的 YOLO 数据集,第一步都该是“打开 classes.txt 数一下行数,逐行读类名”,再对照 2.2 的标签格式去核对一两个文件。
2.2 标签 txt 的每一行在写什么:class_id 和归一化框坐标
YOLO 格式的标注文件不是 XML,也不是 JSON,就是纯文本,每一行表示一个目标。这行里第一个数字是类别 id,后面四个浮点数分别是归一化后的中心点 x、中心点 y、框宽 w、框高 h。所谓归一化,就是除以图像的宽和高,所以全部落在 0 到 1 之间。下面这是某个 train 样本里的真实结构:
# night4/labels/train/000123.txt,每行一个目标 1 0.5310 0.4720 0.1230 0.0860 3 0.3100 0.5820 0.0450 0.0320 0 0.8500 0.4700 0.2800 0.1900第一行的 class_id 是 1,对照 classes.txt 就是 person,后面 0.531 和 0.472 表示这个人在图像横向 53.1%、纵向 47.2% 的位置,0.123、0.086 是目标宽高占整图的比例。第二行 id 是 3,对应 bus,框更扁更长,符合公交车视觉比例。第三行 id 是 0,对应 car。
读取这类文件时有三个细节要注意。第一,txt 文件里的浮点数不要取整,0.5310 和 0.53 在还原像素坐标时会差出几个像素,小目标可能因此偏出半个身位。第二,没有目标的图像对应一个空 txt,这是合法的,不要删,标注工具通常就是这样生成。第三,如果是从 VOC 或 COCO 转过来的数据,txt 里的坐标很容易出现绝对值,那说明没归一化,训练前必须先转换,否则框的位置会完全错乱。
2.3 classes.txt 与 data.yaml:class 文件顺序为什么改不得
class 文件在 YOLO 系列里有两种常见形态:一种是纯文本 classes.txt,一行一个类名;另一种是 data.yaml,里面挂 names 列表。两者功能一样,都是把 class_id 映射回可读的类名。先看 classes.txt 的内容:
cat night4/classes.txt # car # person # motorcycle # bus四行对应四个 id:car=0,person=1,motorcycle=2,bus=3。这个顺序一旦定下来,标签 txt 里的第一个数字就全部按这个顺序写入。data.yaml 里的 names 也必须和它保持完全一致:
path: /home/user/night4 train: images/train val: images/val test: images/test names: 0: car 1: person 2: motorcycle 3: bus这里最关键的一条经验:class 文件的顺序绝对不能动。我见过有人觉得“bus 应该排前面”,把顺序改成 bus、car、person、motorcycle,结果原来所有标签里的 0 从 car 变成了 bus,mAP 直接掉到没法看。这是因为 txt 里存的是 id,不是名字。如果确实要换顺序,正确的做法是写一个小脚本逐行读 txt,把第一列数字按新旧映射表改掉,而不是直接编辑 class 文件。
2.4 夜间遮挡目标标注规则:只标可见部分
夜间场景里目标往往只有一半可见,比如行人被路灯杆挡住、车辆进出画面只露出一角。标注规范如果不统一,会让模型学到错误的边界。我自己用的规则是:只标可见部分,不凭想象补全。
举例来说,行人从画面左侧进入,身体只有 40% 在画面内,那就按这 40% 拉一个框,不要试着把画面外的部分估算进去。车辆停在一堆障碍物后面,只有车头可见,同样只标车头。这样做的好处是,框和图像内容严格对齐,模型在计算 IoU 时不会因为“看不见的部分”产生大量低质量正样本。夜间图像里目标边缘本来就模糊,如果标注框再带“猜测成分”,训练出来的回归分支就会学得犹豫。
这个规则也意味着同一个目标在不同帧里的框大小可能差异很大,这是正常的。夜间连续帧里,一辆车从远处驶近,框从 12 像素宽长到 300 像素宽,模型要学的正是这种连续变化,而不是固定尺寸模板。
3. 划分好的数据集直接开训:目录结构、最小命令与 5 个训练参数
3.1 images/labels 目录与 train、val、test 里放多少张合适
一份能直接开训的数据集,目录结构通常固定成 images 和 labels 两大块。images 下按 train、val、test 三个子目录放图,labels 下按同样结构放 txt。图片和标签靠“同名不同后缀”对应。比如images/val/000123.jpg对应labels/val/000123.txt。整套结构如下:
night4/ ├── images/ │ ├── train/ # 训练图 │ ├── val/ # 验证图 │ └── test/ # 测试图 ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ ├── classes.txt ├── data.yaml ├── visualize_boxes.py # 标注框可视化脚本 └── class_stats.py # 类别分布统计脚本见到这种目录结构,先确认两个事。第一,images 和 labels 下的文件名必须完全一致,哪怕一张图对应一个空 txt 也不能缺;缺失 label 的那张图,训练时要么被跳过要么报错。第二,val 和 test 的比例。我一般接受的比例是 train 占 70% 到 80%,val 占 15% 到 20%,test 留 5% 到 10%。夜间数据集如果总量只有一两千张,val 留太少会让 mAP 波动非常大。
还有一类容易踩的坑:划分是按文件名随机打散的,还是按摄像头来源划分的。前者省事,但同一个摄像头同一段路的连续帧可能同时进 train 和 val,造成“数据泄漏”,验证分数虚高。所以在开训前花五分钟用可视化脚本看一眼 val 里的缩略图,确认没有大量和 train 高度相似的连续帧,这一步值得做。
3.2 用 YOLOv8 跑第一轮:最小训练命令和 5 个必调参数
YOLOv8 是目前训练这类数据集最顺手的入口。我自己训练自己的数据集时,最小命令长这样:
cd /home/user yolo detect train \ data=night4/data.yaml \ model=yolov8s.pt \ epochs=100 \ imgsz=640 \ batch=16 \ device=0这条命令把 5 个参数全部摆到了明面上。data指向 data.yaml,是整个实验的入口;model=yolov8s.pt表示从 COCO 预训练权重开始,而不是从头训练,这对千张量级的数据集影响很大,能少跑很多轮;epochs=100是夜训的起点,太少会欠拟合,太多会过拟合到夜间噪声上;imgsz=640是速度和精度的平衡点;batch=16在单张 8G 显存的卡上比较稳妥。
有几个参数我没写,但新手经常问。project和name控制输出目录,如果不指定会默认落到runs/detect/train。workers控制数据加载线程数,Windows 上建议设为 0 到 2,Linux 上设成 4 到 8 都行。patience是早停轮数,默认 50,意思是 50 轮没提升就停,如果训练集很小,这个值容易误杀后期才出现的提升,我会保守地改成 80 或直接关掉。
3.3 夜间增强参数一页复盘:hsv_v、mosaic、imgsz 怎么调
夜间数据和白天数据在增强参数上的差异,主要体现在亮度扰动和 mosaic 上。YOLOv8 的默认增强参数偏向自然光场景,直接用到夜间数据集上会让模型见过太多“被增强得发蓝发紫”的诡异画面。下面这组是我在夜间车辆行人检测上常用的起点:
yolo detect train \ data=night4/data.yaml \ model=yolov8s.pt \ epochs=120 \ imgsz=640 \ batch=16 \ hsv_h=0.02 \ hsv_s=0.70 \ hsv_v=0.60 \ mosaic=0.80hsv_v是亮度通道的扰动强度,默认 0.4,夜间场景我调到 0.6。原因是夜间图像亮度分布两极分化,有的暗到只能看清轮廓,有的被车灯照得局部过曝,模型需要适应这种亮度差,所以亮度扰动加大有好处。hsv_h保持 0.02 就够了,晚上光照本来就偏单一,调太大容易把车灯颜色学歪。mosaic从 1.0 降到 0.8,因为夜间低对比度下,四张图拼接后目标边界更糊,过度依赖 mosaic 会让小目标更难学。
imgsz这里保留 640,但如果 val 里大量小目标,比如 20×20 像素以下的行人,我会提到 768。代价是训练时间和显存都涨约四成,所以先在 640 上跑通,再决定要不要上 768,这是最稳的节奏。
3.4 换 YOLOv5 或其他 YOLO 变体:目录与 data.yaml 不用动
目录结构和 data.yaml 这套东西在 YOLO 生态里是通用的。换 YOLOv5 时,只需要把训练入口换成它的脚本:
cd yolov5 python train.py \ --data /home/user/night4/data.yaml \ --weights yolov5s.pt \ --epochs 100 \ --batch-size 16 \ --img 640注意 YOLOv5 的--data参数同样指向这份 data.yaml,它读的也是train、val、names这几个字段。不同的是 YOLOv5 对目录的要求更宽松,你甚至可以不用images/train这种结构,直接在 data.yaml 里写绝对路径指向你自己的图片文件夹,它会自动找同名 txt。YOLOv8 则更习惯固定的 images/labels 布局。
如果后续要试 YOLOv11 或 YOLO 系列其他变体,目录结构和 class 文件都不用动,只看训练入口的配置方式。这也是当初做数据集时把 images 和 labels 分开、用 data.yaml 统一描述的原因:迁移成本低,所有的模型代码都认这套约定。
4. 数据可视化脚本:三种用法把 4 类标注翻出来查质量
4.1 标注框可视化脚本:框是否贴合夜间目标
数据可视化脚本是这个数据集里最值得先跑的部分。它做的事情很简单:读取一张夜间图像和对应的 txt 标注,把归一化坐标还原成像素坐标,然后用 OpenCV 把矩形框和类名直接画在图上。代码不长,但很实用:
import argparse import os import cv2 parser = argparse.ArgumentParser() parser.add_argument("--image", required=True, help="图片路径,如 night4/images/val/000123.jpg") parser.add_argument("--data", default="night4", help="数据集根目录,用于读取 classes.txt") args = parser.parse_args() with open(os.path.join(args.data, "classes.txt")) as f: names = [line.strip() for line in f if line.strip()] img = cv2.imread(args.image) H, W = img.shape[:2] # 把 images 路径替换成 labels 路径,jpg 后缀换成 txt label_path = args.image.replace("/images/", "/labels/").rsplit(".", 1)[0] + ".txt" with open(label_path) as f: for line in f: parts = line.split() if len(parts) != 5: continue cid, cx, cy, w, h = map(float, parts) x1 = int((cx - w / 2) * W) y1 = int((cy - h / 2) * H) x2 = int((cx + w / 2) * W) y2 = int((cy + h / 2) * H) color = (0, 255, 0) # BGR 绿色框 cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, names[int(cid)], (x1, max(0, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) out = "vis_" + os.path.basename(args.image) cv2.imwrite(out, img) print("saved:", out)运行方式:
python visualize_boxes.py --image night4/images/val/000123.jpg --data night4这个脚本里最容易改错的地方是路径替换。images/val/000123.jpg换成labels/val/000123.txt,目录层级少一层都不行。跑出来后主要看三点:框是否紧贴目标边缘;夜间暗处目标是否漏标;有没有一个目标被分到两个类里。特别要留意车灯周围,很多标注会把车灯当成一个小目标标进去,这类框会让模型学到“亮斑 = 车辆”。
4.2 类别分布统计脚本:4 类不平衡在哪儿一眼看清
可视化脚本的第二大用途是统计类别分布。训练前看一眼 4 类的实例数,能提前判断模型会不会对某个类严重漏检。脚本直接遍历 labels 目录下的所有 txt,把第一个字段的 id 计数:
import glob from collections import Counter root = "night4" split = "train" # 改成 val 或 test 可以分别统计 stats = Counter() image_files = glob.glob(f"{root}/images/{split}/*.jpg") for imgf in image_files: label = imgf.replace("/images/", "/labels/").rsplit(".", 1)[0] + ".txt" try: with open(label) as f: for line in f: if line.strip(): stats[int(line.split()[0])] += 1 except FileNotFoundError: print("missing label:", label) print(f"{split} images:", len(image_files)) for cid in range(4): print(f"class {cid}: {stats[cid]} instances")跑完后如果看到类似class 0: 3200,class 1: 2800,class 2: 450,class 3: 380这样的结果,说明 motorcycle 和 bus 样本严重不足。这种情况下模型大概率会优先学 car 和 person,两轮车和公交车的 mAP 会明显偏低。处理方式不是简单删图,而是考虑对稀缺类做样本重复采样,或用模型训练时的 cls loss 权重去补偿。可视化脚本在这里的作用是让你在投入训练前就意识到问题,而不是等三个小时后看一张难看的 PR 曲线。
4.3 批量网格抽查:拼一张图看 16 个夜间样本
逐张看图的效率太低,尤其夜间图像看上二三十张之后,人眼对暗部细节会疲劳。更实用的做法是一次取 16 张 val 图,缩放到统一尺寸,拼成 4×4 的网格,一张图同时看 16 个样本的标注情况。我用一个很短的 Python 片段做这件事:
import cv2 import glob files = sorted(glob.glob("night4/images/val/*.jpg"))[:16] cell = 320 # 每格图边长 rows = [] for i in range(0, 16, 4): row_images = [] for f in files[i:i + 4]: im = cv2.imread(f) im = cv2.resize(im, (cell, cell)) row_images.append(im) rows.append(cv2.hconcat(row_images)) grid = cv2.vconcat(rows) cv2.imwrite("visual_grid.jpg", grid) print("saved: visual_grid.jpg,共", len(files), "张")注意这里拼的是原图,不是标注框叠加后的图。我的习惯是先用 4.1 的单张脚本抽两张叠加框的图,再用这个网格看整体的明暗分布、模糊程度、视角多样性。网格图里如果发现大量连续帧几乎一模一样,说明划分时没有按场景去重,这比类别不均衡更隐蔽,也更能拖低验证分数的可信度。
4.4 可视化结果带出的数据清洗动作
可视化脚本看完后,通常会带出三类清洗动作。第一类是删除“不可学”的图:整张接近全黑、目标只露出不足 5% 且被严重遮挡、重复帧。这些图不是难例,是噪声,留在训练集里只会让 loss 曲线反复跳动。第二类是修正标注错误:比如明显把一辆面包车标成 bus、把骑手标成 person,这类错误靠脚本叠框后一眼就能看出来。第三类是补漏标:夜间暗处的行人特别容易被遗漏,如果 val 里大量图像的目标框数量明显少于肉眼可见目标,说明这份数据集的 recall 先天不足。
清洗时要控制删图比例。一次清洗删除超过 15% 的图像,我会谨慎,因为夜间数据集本来就难采集,删太多容易把场景多样性削掉,模型泛化能力会跟着降。宁可保留一部分“难但干净”的图,也不要为了追求完美而把数据集削成单一场景。
5. 避坑排查:夜间训练与数据文件相关的 5 个高频问题
5.1 训练 loss 在降、mAP 却不涨:夜间小目标占比太高
现象:训练 loss 一路下行到 0.03 附近,但 val 上的 mAP50-95 停在 0.25 上不去,train 和 val 的差距也不大。
原因:夜间图像里大量目标非常小。监控视角下 30 米外的行人可能只有 15×30 像素,在 640 分辨率下小于特征图上一个网格的感受野,模型几乎没有能力学出稳定特征。loss 是从训练集里学的,训练集里这类小目标同样存在,但模型学到的是“模糊猜测”,所以 loss 降了,mAP 不涨。
解决:先用可视化脚本统计目标框的像素面积分布,如果大量框面积小于 300 平方像素,把 imgsz 提到 768 或 896 是最直接的方案。显存不够时,推理阶段用切图方式把大图切成 640 的块再合并结果,也能明显改善小目标召回。
5.2 改了 class 文件顺序,预测结果整体错位
现象:训练结束后,val 可视化里 car 全部被标成 person,person 全部被标成 bus,但框的位置完全没变。
原因:class 文件顺序在训练前被改动过,或者标注工具重新导出了一份不同顺序的 classes.txt。标签 txt 里存的是 class_id,它和具体类名的对应关系完全靠顺序。改了顺序,所有 id 的语义整体平移,训练的人看不到任何报错,但预测结果从头错到尾。
解决:不要在已有数据集上改 classes.txt 顺序。如果必须改,先写一个映射脚本,读取旧 id 和旧类名,按新顺序映射成新 id,逐行重写所有 txt。改完之后用可视化脚本抽十张图,检查类名和框的对应关系,这一步不能省。
5.3 混淆矩阵行和不是 1:一类目标被打了两类标签
现象:训练完成后看 confusion_matrix.png,发现某些行的百分比加起来超过 100%,person 和 motorcycle 之间互相污染严重。
原因:标注规范不统一。骑摩托车的人如果被同时标了 person 和 motorcycle,同一个视觉目标就有了两个框,且这两个框高度重叠。模型训练时一会儿把这片区域当 person,一会儿当 motorcycle,最终这两类的混淆吞噬掉大量样本。
解决:回到 2.1 节的标注规则,明确“骑在车上的人只标 motorcycle”。然后写一个脚本检查同一张图里不同类别框的 IoU,如果 IoU 超过 0.5 且类别不同,输出到待修列表,人工确认后删除冗余框。
5.4 batch 太小导致 BN 崩溃:loss 训练中突然变 NaN
现象:训练到第 30 个 epoch,loss 毫无预兆地变成 NaN,重启训练后又大概率在类似位置复发。
原因:BatchNorm 在 batch 太小(比如 2 或 4)时,均值和方差的估计噪声非常大。夜间图像本身亮度方差高,加上小 batch 的统计波动,跑几十轮后数值直接溢出。8G 显存跑 640 分辨率,很多人会下意识把 batch 降到 4,这恰恰是导火索。
解决:batch 至少保持 8,显存不够就先降 imgsz,而不是降 batch。要么保持 batch=4 但开启梯度累积,让有效 batch 回到 16。另一个备用方案是把model从yolov8s.pt换成yolov8n.pt,模型更小,显存占用更低,batch 也能维持住。
5.5 可视化脚本大批量跑 OOM:先攒网格再写盘
现象:跑批量可视化时,脚本一次性读入 1000 张图,内存冲到 20G 以上,程序被杀,一张输出也没留下。
原因:把整个目录的文件一次性读进列表再统一画图,图像解码后的原始矩阵非常占内存。一张 1920×1080 的彩色图约 6MB,1000 张就是 6GB,叠加 Python 自身的对象开销,内存很容易爆。
解决:改用分批处理,每批 16 张拼成一张网格图,写完盘就释放内存。4.3 节的网格脚本本质上就是在做这件事:每轮只持有 16 张图,拼完立即imwrite,下一轮覆盖同一组变量,内存占用被压在常数级别。
6. 进阶验证:把可视化脚本变成回归测试,盯住夜间模型每一版改进
6.1 每次训练生成 20 张 val 快照,人工对比上一版
夜间检测模型迭代最大的风险不是精度不涨,而是“这版修好了摩托车,却把 bus 搞崩了”。我现在的习惯是每次训练完,立刻从 val 里固定抽 20 张图,调用可视化脚本叠加标注框,保存到以实验名命名的目录里:
mkdir -p runs/snapshots/exp_037 for f in $(ls night4/images/val/*.jpg | head -20); do python visualize_boxes.py --image "$f" --data night4 mv vis_*.jpg runs/snapshots/exp_037/ done关键是这 20 张图每次固定不变,相当于给模型做回归测试。对比上一版快照,重点看两类变化:之前能检出的目标现在是否还在;车灯、反光区域有没有新增误检。这类退化 mAP 数字上看不出来,但快照对比一眼就能发现。我一般把快照目录按日期归档,两周后翻出来还能定位是哪个改动引入的倒退。
6.2 用同一个 data.yaml 复跑 val,输出混淆矩阵和 PR 曲线
所有改进实验收尾时,用同一份 data.yaml 和同一个 val 集复跑验证,是保证结论可比的前提。命令很简单:
yolo detect val \ data=night4/data.yaml \ model=runs/detect/train_exp037/weights/best.pt跑完后在输出目录里找confusion_matrix.png和PR_curve.png。对比不同版本时,盯住两个具体位置:一是 person 和 motorcycle 之间的混淆强度,二是 PR 曲线上 recall 到 0.8 附近时 precision 的掉速。这两处往往能提前暴露数据标注问题,比只看 mAP 一个数字可靠得多。
这套验证流程做完,改 class 顺序、调增强参数、换模型结构时,就有了一个固定的回退锚点。我现在无论换哪个 YOLO 变体,都先跑一遍可视化脚本确认 class 映射,再开训练,最后把快照回归跑一遍。这套流程救过我太多次,也希望帮到你。
本文还有配套的精品资源,点击获取