简介面向夜间车辆检测任务的学习者与开发者这份数据集包含5000张真实场景高质量图片已用LabelImg完成标注且同时提供VOC(xml)、COCO(json)和YOLO(txt)三种标签格式可直接接入YOLO系列模型训练。压缩包内共2000个文件除1986个xml标注文件外还包含6个html图文教程、5个txt索引列表和3个Python划分脚本整体大小约209MB。教程内容覆盖Windows与Linux两种环境下的YOLO环境搭建、训练案例以及如何修改案例以训练自定义数据集三个脚本则支持将图片与标签按比例划分到训练集、验证集和测试集让数据准备过程更省心。资源尤其适合夜间行车监控、安防巡检等场景的研究与项目开发能从数据标注、格式转换到模型训练形成完整闭环。目前已有449人学习下载是一份数据与教程配套的实用入门资源。1. 夜间车辆检测白天训好的YOLO为什么一到晚上就掉点用白天数据训出来的YOLO车辆检测模型到了夜间场景mAP从0.87掉到0.41漏检率翻一倍以上这种事我在项目里见过不止一次。这通常不是网络结构的问题而是训练样本的光照分布和真实场景严重脱节。YOLO夜间车辆检测数据集就是冲着这个分布差来的它收录约5000张夜间车辆图像带VOC、COCO、YOLO三种格式标签配套划分脚本和训练教程正适合正在做智能交通、夜间安防以及准备在AGX Orin这类边缘设备上部署检测模型的从业者。下面按格式转换、场景划分、训练避坑、进阶验证四条线把从拿到数据到训出可用模型的完整路径捋一遍。2. VOC、COCO、YOLO三种标签格式选型逻辑与一键转换脚本解压数据集包后我习惯先花几分钟把images和labels目录的组织方式看一遍再决定用哪种格式作为解析入口。常见的组织方式是images目录放jpg原图labels目录下再按voc、coco、yolo三个子目录分开放标签这样三套标注互不干扰也方便统一切换和校验。2.1 三种格式的存储结构差异VOC格式严格来说是Pascal VOC的组织形式每张图像对应一个同名xml文件。xml的annotation节点下每个object子节点描述一个目标name字段存类别名bndbox节点里是xmin、ymin、xmax、ymax四个绝对像素整数坐标。这种结构人工可读性最好直接用文本编辑器打开就能核对标注内容适合作为原始标注的存档格式。COCO格式则反过来整个数据集的标注汇总成一个json文件。json里分为images、annotations、categories三个主数组annotations里每条记录通过image_id关联图像bbox字段是[x, y, width, height]的绝对像素浮点数组。json格式为segmentation和area字段预留了位置如果后续检测项目想升级到YOLO实例分割这套COCO标签可以直接平移过去不用重新标一遍。YOLO格式是训练阶段实际喂给模型的格式每张图像对应一个同名txt文件。每一行描述一个目标格式是class_id、x_center、y_center、width、height五个数值全部归一化到0到1之间。归一化的好处是模型换输入分辨率时不用重标标签640x640和1280x1280训练共用同一套txt。三种格式的用途我是这么分工的原始标注存档和人工核对用VOC评测指标计算用COCO实际训练用YOLO。这样每个环节各取所需互不污染。格式存储载体bbox坐标定义我常用的场景VOCxml每图一个xmin ymin xmax ymax绝对像素整数标注存档、人工核对COCOjson全集合一个[x, y, w, h]绝对像素浮点cocoapi评测、升级实例分割YOLOtxt每图一个class x_center y_center w h归一化直接喂给模型训练2.2 用Python脚本把VOC标签一次性转成COCO和YOLO转换脚本有两个容易出错的地方类别id的映射顺序和坐标边界处理。类别映射我习惯写成一个独立字典按固定顺序给id不要用字典遍历时的随机顺序否则每次运行生成的类别id都不一样训练和评测对不上。坐标边界处理是为了兜住xml里的越界框这类脏数据一旦混进YOLO训练集轻则loss波动重则某个类别整体学歪。import xml.etree.ElementTree as ET CLASS_MAP { car: 0, truck: 1, bus: 2, motorcycle: 3, pedestrian: 4, } def voc_to_yolo(xml_path, img_w, img_h): 读取一个VOC xml输出YOLO格式的txt行列表 tree ET.parse(xml_path) root tree.getroot() lines [] for obj in root.iter(object): cls obj.find(name).text if cls not in CLASS_MAP: continue bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) # 边界夹取防止负坐标或超宽坐标进入训练集 xmin max(0.0, min(xmin, img_w)) xmax max(0.0, min(xmax, img_w)) ymin max(0.0, min(ymin, img_h)) ymax max(0.0, min(ymax, img_h)) if xmax - xmin 1 or ymax - ymin 1: continue # 过滤退化框避免yolo宽高趋近0 x_center (xmin xmax) / 2.0 / img_w y_center (ymin ymax) / 2.0 / img_h w (xmax - xmin) / img_w h (ymax - ymin) / img_h lines.append(f{CLASS_MAP[cls]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) return lines这段代码的逻辑是按xml里的object节点逐个取出类别和真实框坐标先做边界夹取再做归一化。img_w和img_h这两个参数建议直接从PIL读原图尺寸获取不要读xml里的size节点因为部分标注工具写进xml的图像尺寸和真实图像不一致转换后所有框都会偏移。COCO格式的转换不一样需要额外维护一个递增的annotation id并把归一化坐标反推回绝对像素的bbox数组。下面这个函数复用上面的voc_to_yolo做解析能保证两套格式同源交叉评测时不会出现坐标系不一致的问题。import json, os from PIL import Image def voc_to_coco(xml_list, output_json): images, annotations, ann_id [], [], 1 categories [{id: v, name: k} for k, v in CLASS_MAP.items()] for img_id, xml_path in enumerate(xml_list, start1): jpg_path xml_path.replace(.xml, .jpg) img Image.open(jpg_path) w, h img.size images.append({ id: img_id, file_name: os.path.basename(jpg_path), width: w, height: h, }) for line in voc_to_yolo(xml_path, w, h): cls_id, xc, yc, bw, bh map(float, line.split()) x (xc - bw / 2) * w y (yc - bh / 2) * h annotations.append({ id: ann_id, image_id: img_id, category_id: int(cls_id), bbox: [round(x, 2), round(y, 2), round(bw * w, 2), round(bh * h, 2)], area: round(bw * bh * w * h, 2), iscrowd: 0, }) ann_id 1 with open(output_json, w) as f: json.dump({images: images, annotations: annotations, categories: categories}, f, indent2)这个函数里img_id从1开始递增annotation id全程不重置满足cocoapi对id唯一性的要求。bbox反推后做了round保留两位小数避免超长浮点在评测时出现解析边界问题。之所以没有在反推前再次夹取边界是因为voc_to_yolo里已经修过一轮这里反推回去的坐标必然落在图像范围内。2.3 转换后必须做的四类标签校验转换完不要急着开训先花十分钟做四类校验能省掉后面几个小时的排查时间。第一是数量校验统计xml里的object总数、json里的annotations条目数、txt里的总行数三者应该完全一致。不一致通常说明有类别不在CLASS_MAP里被跳过或者某个xml解析失败。第二是坐标范围校验遍历每张图的yolo行确认w和h都在(0, 1]区间、中心点都在[0, 1]内任何超范围的值都会在训练时引发边界回归异常。第三是退化框校验统计宽高小于原图尺寸1%的框夜间小目标经常出现这种极窄框如果占比超过5%先回头检查标注质量。第四是可视化抽查随机抽20张图用opencv把归一化坐标乘回原图尺寸画框人工快速扫一遍。提示可视化抽查这一步最容易被跳过但它恰恰是发现坐标系转换错误最快的路径。我遇到过转换脚本把x和y写反的情况指标看着正常一画框全跑偏这种问题单靠统计校验查不出来。3. 数据划分脚本先分场景再分样本避免同源帧泄漏数据划分看起来是几个random.shuffle加一个路径拷贝实际上对夜间车辆数据集来说这里藏着最容易让指标虚高的一个坑。划分脚本做得对后续训练和评测才有参考价值。3.1 随机划分的隐患同源帧泄漏夜间车辆数据的图片来源一般分两类一类是人工拍摄的离散照片画面之间独立性较强另一类是从监控视频和行车记录仪里按帧抽取的连拍帧。后者在文件名里通常会保留场景前缀和帧号类似scene01_000012.jpg和scene01_000013.jpg间隔几十毫秒的两帧里车辆位置几乎没变化。如果划分脚本直接对全部文件名做random.shuffle再按比例切同一个场景的相邻帧大概率被拆到训练集和验证集两边。模型在训练集里学过前一帧验证集里再看到几乎同帧的画面mAP虚高就是必然结果。白天数据上这种虚高没那么致命因为光照和姿态变化快帧间差异大夜间数据灯光相对稳定、帧间变化小同源帧泄漏的杀伤力会被明显放大。我复盘过一个实际案例训练结束验证集mAP打印出来0.91部署到现场只有0.6不到。逐项排查下来问题不在超参、不在网络结构就是划分脚本随机切分导致验证集被污染。这类问题不报错训练过程完全正常事后复盘成本却很高只能在划分环节用纪律防范。3.2 按文件前缀聚合场景再划分的实现划分脚本的核心思路是先把图片按场景粒度分组再在场景维度上做切分保证同一个场景的帧只能落进训练集、验证集、测试集三者之一。场景识别的依据可以是文件名前缀、目录名、时间戳窗口最可靠的是文件名里的场景id前缀。import os, random, shutil from collections import defaultdict data_root night_vehicle image_dir os.path.join(data_root, images) scene_group defaultdict(list) for fname in os.listdir(image_dir): if not fname.endswith(.jpg): continue # 文件命名约定sceneID_frameID.jpg例如 night01_000123.jpg scene_id fname.rsplit(_, 1)[0] scene_group[scene_id].append(fname) scenes list(scene_group.keys()) random.seed(42) random.shuffle(scenes) train_ratio, val_ratio 0.7, 0.15 n_train max(1, int(len(scenes) * train_ratio)) n_val max(1, int(len(scenes) * val_ratio)) split_map {} for idx, scene in enumerate(scenes): if idx n_train: split_map[scene] train elif idx n_train n_val: split_map[scene] val else: split_map[scene] test for scene, fnames in scene_group.items(): target split_map[scene] for fname in fnames: src_img os.path.join(image_dir, fname) dst_dir os.path.join(data_root, target, images) os.makedirs(dst_dir, exist_okTrue) shutil.copy(src_img, os.path.join(dst_dir, fname))这个脚本最关键的参数是random.seed(42)和train_ratio。固定随机种子别人复现你的实验时划分结果才能完全一致否则每次运行得到的训练集都不同实验之间的差异就说不清是模型改动的功劳还是数据划分的锅。fname.rsplit(_, 1)[0]假设场景id和帧号之间用下划线分隔如果命名是night01-000123.jpg这种中划线风格把分隔符替换成-即可。分割逻辑上先把scenes列表打乱再按比例切成三段然后把每个场景的帧复制到对应split目录。用shutil.copy而不是move是为了保留一份完整原始数据作为备份训练集即使后期被污染也不用重新解压数据包。3.3 划分脚本的三个可调参数与输出校验三个最常用的可调参数是train_ratio、val_ratio和random_seed。train_ratio我习惯设在0.7到0.8之间夜间小目标占比高样本总量本来就紧张验证集留太多会进一步压缩训练量。random_seed在调试阶段固定写入如果要做交叉验证可以循环更换种子但每轮都要完整重跑划分脚本并单独记录划分清单。划分完要做两组校验。第一组是数量校验打印三个split各自的图片张数和标注框数因为按场景分组实际比例与设定ratios会存在几个百分点的浮动属于正常现象。第二组是类别分布校验把每个split的每类框数量打印出来对比如果车灯小目标类别在训练集里明显偏少说明场景分组粒度太粗需要回到3.2的脚本把该类目标密集的场景组拆细。import glob from collections import defaultdict def count_boxes(split_dir): total_imgs 0 total_boxes 0 cls_counter defaultdict(int) for label_path in glob.glob(os.path.join(split_dir, labels, *.txt)): total_imgs 1 with open(label_path) as f: for line in f: cls_id int(line.split()[0]) cls_counter[cls_id] 1 total_boxes 1 return total_imgs, total_boxes, cls_counter调用时直接传train或val的labels目录路径即可。这个函数里的split_dir路径要和3.2脚本生成的一致labels目录需要同步按同一份split_map把yolo标签拷贝过去我一般会在3.2的循环里用相同逻辑复制同名txt避免手工同步出错。如果训练集和验证集的类别比例偏差超过10%我基本不会硬训而是回到划分脚本重排场景组。这一步是数据流动里最容易跳过的环节但对夜间交通数据来说车灯小目标类别在训练集占比不足直接导致模型对远距离车辆的召回偏低。4. 训练教程避坑指南夜间数据喂给YOLO的五个高发翻车现场数据集配套的训练教程通常从下载YOLO代码、准备数据yaml、启动训练、看曲线、导出模型这条标准路径开始。对刚入门YOLO的读者来说照着走能跑通流程但夜间数据有一个特点就是步骤全对结果也可能翻车。下面五类问题是我在本地和边缘设备上反复遇到过的写出来当一面提前竖好的避坑牌。4.1 现象train_loss持续下降val_mAP却纹丝不动loss曲线非常漂亮地一路下降但每个epoch结束后的val_mAP停在0.3上下不动这种割裂感是夜间训练最典型的翻车现场。原因集中在两点类别不均衡和难例被稀释。夜间数据里车灯小目标往往占了大比例这些框只有20x20像素甚至更小对loss的贡献被大量中大型车辆目标稀释模型整体方向没错但对小目标类别始终没学好。解决思路优先调整YOLO训练命令里的loss权重。常见做法是把box和cls两个loss项的权重适当上调并把置信度阈值调低观察小目标召回。第二个有效手段是提高训练分辨率夜间小目标在640输入下只有十几个像素特征基本被下采样抹掉把imgsz从640提到960或1280问题往往直接缓解。4.2 现象验证集mAP虚高到0.9现场一测就露馅验证集mAP稳定在0.85以上模型看起来非常好一到真实夜间现场就漏检远距离车辆和侧向来车。这类问题大概率出在划分环节同源帧泄漏让验证集混入了训练集相邻帧指标虚高到没有参考价值。排查方法很简单随机抽几张验证集图片去训练集里找同样场景id的帧能对上就实锤了。处理方式不是调参而是回到第3章的划分脚本重做数据切分。数据集附带的划分脚本如果默认是纯随机切分运行之前先看一遍代码逻辑确认有没有场景分组。另外注意测试集一定要独立冻结不能参与训练过程中的任何回炉微调否则现场表现始终有水分。4.3 现象车灯亮斑被误检成车远光灯一照全是框夜间图像里路灯灯罩、交通标志反光板、远处楼顶广告灯牌都可能被模型判定成car或truck。原因是夜间车辆特征里车灯亮斑加黑色剪影的组合占了主导模型学到的其实是高亮圆形区域和暗色背景的关系一旦画面出现类似结构就会误激活。解决路径有两条。一是往训练集里混入一批不含任何车辆但包含路灯和反光物的夜间负样本标签留空或者单独划一个background类别。二是调低数据增强里mosaic拼接的概率mosaic会把不同场景的亮斑强行拼到一张图上进一步放大亮斑误检。4.4 现象训练中途loss变成nan夜间数据训练偶尔会在四五十个epoch附近loss变成nan。先把学习率降一个数量级跑一版多数情况下是lr过大导致梯度爆炸降到1e-4以下就能恢复。如果降lr依然nan去查标签里有没有坐标为inf或明显超界的框。在PyCharm里排查这个问题时一个高效的做法是用debug断点打印出现nan前一个batch的标签矩阵直接定位是哪张图的哪个框出了问题。我遇到过一张xml里xmax被标成图像宽两倍的情况转换时没有被边界夹取修掉这类脏数据要回到2.2的转换脚本加校验而不是在训练侧硬扛。如果开了AMP混合精度训练还有一个隐蔽来源是fp16下的inf溢出关掉amp重跑即可确认。4.5 现象AGX Orin上batch一开大就OOM在AGX Orin这类边缘设备上训练或部署batch从16调到32立刻显存溢出。Orin的GPU内存和系统内存共享默认情况下系统为桌面显示保留了不少内存训练前先用nvpmodel把运行模式切到MAXN释放算力上限再把内存分配策略调整到位。常见做法是训练脚本里把data loader的num_workers从4降到2并开启AMP。Orin平台跑yolov8nbatch 16加AMP可以稳定运行batch 32建议换更小的骨干或者走TensorRT的int8量化。推理侧的一键部署脚本在Orin上顺不顺很大程度上取决于这些前置设置而不是模型本身。5. 进阶验证用混淆矩阵和误报聚类判断模型是否真正适应了夜间场景mAP过了0.7只代表整体召回和精度平衡得不错夜间场景还要额外看错误结构。我会在验证完成后把混淆矩阵导出来重点关注两类错误真实车辆被判成背景的比例以及背景被判成车辆的虚警比例。前者对应漏检远距离车辆后者对应车灯亮斑误检这两类错误在夜间数据上的权重与白天完全不同只看mAP容易把模型误判成可用。误报聚类的做法是把验证集上模型输出且与真实框IoU低于0.1的预测框按置信度从高到低取前200个裁剪成小图再用两个维度归档——按目标形状分成高亮圆形、细长反光条、局部剪影按来源环境分成路灯、标牌、车灯晕影、广告灯。这类统计能直接回答模型学到的到底是车还是光斑# 伪代码统计虚警框的场景来源 import collections false_alarm collections.Counter() for img, preds in val_predictions: for cls, conf, box in preds: if iou_with_any_gt(box) 0.1 and conf 0.5: crop img[box] # 按预测框裁剪 kind scene_judge(crop) # 路灯/标牌/车灯晕影/剪影 false_alarm[kind] 1聚类结果里如果路灯亮斑和反光标牌两类虚警加起来超过一半说明模型在靠亮度特征判别而不是车辆结构特征后面做多模态分析或者智慧交通事故检测时这类误检会被下游逻辑持续放大比漏检更难处理。我现在的一个固定习惯是训练完不只记mAP还会存一份error_analysis目录把所有虚警框裁剪归档。下一轮迭代先看归档图再决定是加负样本还是调loss权重比盯着loss曲线猜原因快得多。夜间场景的模型验收指标过了只算一半错误结构合理才算真正落地。希望帮到你。本文还有配套的精品资源点击获取