首页 / 资讯中心 / 文章详情

夜间车辆检测数据集实战:从标注解析到YOLO训练避坑

夜间车辆检测数据集实战:从标注解析到YOLO训练避坑 ★ FEATURED ARTICLE
简介夜间车辆检测数据集面向自动驾驶、智能交通监控与安全预警等应用适用于需要训练低光照条件下车辆检测模型的算法工程师、研究人员。资源包含约一万张已标注图像配套的XML标签文件提供边界框坐标与类别信息可支撑YOLO、Faster R-CNN、SSD等主流目标检测模型的训练与评估。压缩包共两千个文件以jpg图像、xml标注和txt辅助文件为主整体大小约二百七十二兆字节。目前已有两千七百余人学习/下载。数据集覆盖街灯、月光、星光及无光源等多种夜间环境能帮助模型适应车灯反射、阴影与低对比度等难点使用时可配合图像增强、参数调优和mAP指标迭代验证是一套可直接用于低照度车辆检测研究与实际工程落地的标注数据资源。1. 夜间车辆检测数据集白天指标再高晚上照样翻车夜间车辆检测数据集已标注在业内意味着你跳过了最痛苦的采集与标注阶段直接进入模型训练和迭代。但拿到标注不等于拿到效果白天验证集上表现漂亮的模型换到夜间场景掉点经常超过百分之二十这不是因为「天黑看不见」而是夜间样本的光源混杂、动态范围极端、背景噪声密集照搬白天训练方案大概率水土不服。这篇笔记从数据集结构拆起讲清标注怎么读、怎么转为训练框架需要的格式、怎么划分并配置夜间训练再把夜间场景里最常踩的坑逐个排掉。适合正在做智能监控、车路协同感知或者夜间辅助驾驶方向的同学。2. 数据集到底给了你什么图片、标注与类别的结构拆解2.1 已标注的三种形态检测框、多边形与忽略区「已标注」这三个字在水准上是分层的。最常见的是检测框标注每个目标给一个矩形框对应目标检测任务这也是消费这类数据集最省事的形态。第二种是多边形或分割掩码它在夜间场景的好处是能把车灯的放射状光晕排除在目标边缘之外矩形框做不到这一点但你要多一步把掩码转成外接矩形或直接训练分割模型。第三种是带忽略区的标注标注员把「看不清但确实有东西」的区域标成 difficult 或 ignore这类区域在夜间数据集里比例不低远光灯直射区、雨雾模糊区都会出现。拿到数据集第一件事是分类别看标注类型而不是直接开训。如果你手里的标注是矩形框检查框是不是贴着车身紧边界如果框把车灯外溢的光晕也包进去后处理时要考虑用置信度阈值或尺寸约束过滤一部分。多边形标注通常质量更高但要额外写转换逻辑。忽略区标注在夜间数据里反而是宝它告诉模型「这里有干扰但别学」训练时能显著降低误检后面转换脚本里会专门处理它。2.2 文件目录与命名规则训练前必须确认三件事正规一点的夜间车辆检测数据集会按图片、标注、类别定义三个目录组织图片与标注文件名一一对应后缀不同。先确认三件事第一是否已经划分好 train、val、test如果没有你自己得按后续第四部分的方法重排第二类别定义文件里到底有哪几类常见是 car 与 truck 两类也可能细化成 car、truck、bus、motorcycle、bicycle夜间摩托车目标小、样本少是后续掉点的高发区第三标注是否统一坐标系像素坐标和归一化坐标混用的情况并不少见。目录结构一般长这样拿到后先 pad 检查dataset/ ├── images/ │ ├── night_001.jpg │ └── night_002.jpg ├── annotations/ │ ├── night_001.xml │ └── night_002.xml └── classes.txt命名规则直接决定你后面写批处理脚本的难度。图片名包含时间戳或摄像头编号都很正常但转换脚本只关心文件名能否一一对应不要依赖名字里的语义信息去推断光照条件那是后面验证阶段的事。2.3 一条标注记录是怎么描述「一辆夜间车」的以 XML 风格标注为例一条记录会包含图片名、图片宽高、目标类别、矩形框坐标以及可选的 difficult 标志。夜间标注里常见的现象是same 目标在不同图片里框大小差异极大近处货车占满画面远处小轿车只有几十像素同时同一个目标可能被车灯光晕包围标注员会把框稍微往外扩一点这对后续训练是有噪声的你需要心里有数。用一小段代码解析单条 XML搞清楚字段再谈转换import xml.etree.ElementTree as ET tree ET.parse(annotations/night_001.xml) root tree.getroot() print(file:, root.findtext(filename)) print(size:, root.findtext(size/width), root.findtext(size/height)) for obj in root.findall(object): name obj.findtext(name) difficult int(obj.findtext(difficult, 0)) bndbox obj.find(bndbox) xmin float(bndbox.findtext(xmin)) ymin float(bndbox.findtext(ymin)) xmax float(bndbox.findtext(xmax)) ymax float(bndbox.findtext(ymax)) print(name, difficult, (xmin, ymin, xmax, ymax))这里的关键是把 bndbox 取成 float 而不是 int因为夜间标注框经常出现像素级小数difficult 字段默认按 0 处理如果直接忽略它后面训练时模糊区域会被当成正向样本。改这行代码之前先跑一遍全量 XML 统计看看有多少目标被标成 difficult占比超过百分之五就说明这批数据的夜间难点集中在低可见度目标上。3. 把 VOC 转成 YOLO 格式转换脚本与四个边界坑3.1 为什么一定要转成 YOLO 格式多数轻量部署链路和主流训练框架都原生读 YOLO 的 txt 标签一行一个目标格式是 class_id、x_center、y_center、width、height全部相对图片宽高做归一化。VOC 的 XML 里存的是像素绝对坐标模型框架读起来要额外写数据加载器容易在 batch 并行时踩坑。转成 YOLO 格式还有一个实际好处你可以直接把它喂给带 mosaic、mixup 这些增强的常见训练管线中间不用改数据读取。3.2 转换脚本XML 到 TXT 全量批处理下面这段脚本把某个目录下所有 XML 转成对应 txt并自动跳过 missing image 的情况。我一般会先跑一遍 dry-run 再真正落盘避免路径不对导致把几百个空文件写进 labels 目录。import xml.etree.ElementTree as ET from pathlib import Path from PIL import Image CLASSES [car, truck, bus, motorcycle, bicycle] IMG_SUFFIX [.jpg, .jpeg, .png, .bmp] def xml_bbox_to_yolo(xml_path: Path, img_path: Path) - str: root ET.parse(xml_path).getroot() with Image.open(img_path) as im: img_w, img_h im.size lines [] for obj in root.findall(object): if int(obj.findtext(difficult, 0)) 1: continue name obj.findtext(name) if name not in CLASSES: continue class_id CLASSES.index(name) bndbox obj.find(bndbox) xmin float(bndbox.findtext(xmin)) ymin float(bndbox.findtext(ymin)) xmax float(bndbox.findtext(xmax)) ymax float(bndbox.findtext(ymax)) x_center (xmin xmax) / 2 / img_w y_center (ymin ymax) / 2 / img_h box_w (xmax - xmin) / img_w box_h (ymax - ymin) / img_h lines.append(f{class_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}) return \n.join(lines) xml_dir Path(annotations) img_dir Path(images) out_dir Path(labels) for xml_path in sorted(xml_dir.glob(*.xml)): img_path None for suffix in IMG_SUFFIX: candidate img_dir / xml_path.stem if candidate.with_suffix(suffix).exists(): img_path candidate.with_suffix(suffix) break if img_path is None: print(f[skip] missing image: {xml_path.stem}) continue yolo_text xml_bbox_to_yolo(xml_path, img_path) out_dir.mkdir(exist_okTrue) (out_dir / xml_path.stem).with_suffix(.txt).write_text(yolo_text, encodingutf-8)代码逻辑是从 XML 里读目标列表同时用 PIL 拿图片真实宽高逐框计算归一化几何中心与宽高。CLASSES 的顺序决定了每个类别在训练时的索引必须与模型配置完全一致哪怕顺序颠倒一个位置整个训练就废了。IMG_SUFFIX 覆盖常见图像扩展名处理前确认你的数据集后缀是不是全统一别只写了 .jpg 结果一批 .png 被静默跳过。参数上值得单独调的地方是几个人为选择CLASSES 只保留数据集中真实出现的类别宁可少也不要多保留difficult 1的跳过逻辑这是夜间场景的关键先验归一化保留 6 位小数对像素坐标 1920×1080 的图足够再长的小数没有实际精度提升。3.3 边界坑一尺寸读错导致全部框漂移转录过程中最常见的翻车是把 XML 里的size当图片真实尺寸而实际上标注工具存下来的可能是缩放后的尺寸或者是标注时用的预览图尺寸。现象是转换后所有框整体偏移 5% 到 20%拿可视化工具一叠图就发现框全部偏向左上角或右下角而且偏移量随目标位置变化不是固定平移。解决方式只有一条永远以Image.open(img_path).size为准XML 里的 size 只做核对不一致时打印警告。之前碰到过一次数据XML 声明 960×540图片实际是 1920×1080用前者归一化出来的 label 在训练时全部错位。还要注意某些移动端拍摄的图片带 EXIF 旋转信息PIL 打开时部分版本读到的尺寸和实际展示方向相反稳妥做法是先把所有图片统一转正再跑转换别在 labels 里把旋转问题带进训练。3.4 边界坑二difficult 框没过滤模型被迫学模糊目标夜间标注员会在远光灯、逆光、雨雾区域里把「轮廓不清晰但确实有东西」的目标标成 difficult这个字段在 VOC 里语义是「不应参与训练指标计算」。如果你转换时无视它模型会把这些模糊目标当成正常正样本训练时反复看到高噪声框最终学到的结果是白天正常目标也能检出但置信度普遍被拉低夜间远处小目标几乎全丢。我的习惯是转换阶段直接跳过 difficult但单独统计这类框的数量# 统计 difficult 分布便于评估数据难度 import xml.etree.ElementTree as ET from pathlib import Path total 0 diff 0 for xml_path in Path(annotations).glob(*.xml): root ET.parse(xml_path).getroot() for obj in root.findall(object): total 1 if int(obj.findtext(difficult, 0)) 1: diff 1 print(fdifficult ratio: {diff / total:.2%})如果 difficult 占比超过百分之十说明数据采集环境相当恶劣训练时要把 conf_thres 调高到 0.35 左右再评估别用默认的 0.25。3.5 边界坑三空标注文件与类别索引错位夜间图像里一整帧没车的情况很常见标注工具可能生成一个空对象 XML 或者干脆不生成对应文件。转成 YOLO 后空标签文件是 0 字节这不是错误但如果缺的是 XML 而不是内容为空很多框架会静默跳过这张图你以为是 4000 张的训练集实际只训了 3800 张数据集的夜间样本占比也被悄悄改写。更隐蔽的是类别索引错位数据本身的 classes.txt 里 car 是第 0 类但训练配置里把 background 塞进第 0 类夜间数据的类别就全员往后挪了一位。现象是 loss 正常下降验证指标也正常但可视化出来所有框都套在目标的左边或右边。转换完先跑一行命令把类别索引和 NAME 对照打印出来再开训练。3.6 边界坑四重叠目标与一个框套两辆车夜间远光灯会让两辆并排车在画面里连成一片高亮标注员可能只标了一个框也可能标了两个高度重叠的框。前者导致模型把两辆车学成一个目标后者导致 NMS 后两个框互相抑制Deepsort 这类跟踪器会频繁跳 ID。解决思路是训练前做一次框重叠度统计计算同一张图内所有框的 IoU大于 0.7 的样本单独列出来人工复核是应该拆框还是保留。拆框操作没有通用自动化方案但至少统计能让你知道这批数据里有多少这种疑难样本别等到训练完做可视化才懵。4. 训练集怎么划分才不白费标注7:2:1 与夜间样本筛选清单4.1 7:2:1 只是起点先按「片段」分组再划分很多同学拿到数据集直接随机打散按比例分 train、val、test这在监控场景里会出大问题相邻帧来自同一段视频画面只有轻微变化随机打散后同一辆车可能同时出现在训练集和测试集验证指标虚高部署时一换摄像头角度就现出原形。正确做法是按视频片段或拍摄时段分组同一个片段内的帧必须划到同一个集合。具体操作是先看文件名里有没有片段 ID比如clip_003_frame_021.jpg这种命名按clip_003分组再把整个组放进某个集合。如果文件名没有片段信息就按时间戳窗口粗分比如每 30 秒作为一个组。这一步做完再谈 7:2:1否则比例根本没有意义。4.2 划分脚本带种子、带配对移动下面脚本演示了按文件名分组、组级别打散、图片与标签配对移动的完整流程import random import shutil from collections import defaultdict from pathlib import Path ROOT Path(dataset) IMG_DIR ROOT / images LABEL_DIR ROOT / labels SEED 2024 TRAIN_RATIO 0.7 VAL_RATIO 0.2 def group_key(img_path: Path) - str: # 按 clip 前缀分组例如 clip_003_frame_021.jpg - clip_003 return img_path.stem.split(_frame_)[0] groups defaultdict(list) for img_path in sorted(IMG_DIR.glob(*.jpg)): groups[group_key(img_path)].append(img_path) group_names list(groups.keys()) random.seed(SEED) random.shuffle(group_names) n len(group_names) train_groups set(group_names[: int(n * TRAIN_RATIO)]) val_groups set(group_names[int(n * TRAIN_RATIO): int(n * (TRAIN_RATIO VAL_RATIO))]) for group in group_names: if group in train_groups: split_name train elif group in val_groups: split_name val else: split_name test for img_path in groups[group]: label_path LABEL_DIR / img_path.stem label_path label_path.with_suffix(.txt) if not label_path.exists(): print(f[warn] missing label: {label_path}) continue dst_img ROOT / split_name / images / img_path.name dst_lbl ROOT / split_name / labels / label_path.name dst_img.parent.mkdir(parentsTrue, exist_okTrue) dst_lbl.parent.mkdir(parentsTrue, exist_okTrue) shutil.copy2(img_path, dst_img) shutil.copy2(label_path, dst_lbl)核心逻辑是按 group_key 分组后在组级别打散然后整组划入 train、val、test。种子固定为 2024 保证可复现换你自己数据时换一个种子即可不需要每次重新思考划分策略。这里要做两个参数决策一是片段大小如果所有片段都非常长可以把连续 1500 帧拆成每 5 秒一个子组再用子组划分二是验证集是否专设夜间子集后面第五部分会提到监控场景下我最推荐的做法是 val 混少量白天数据用于调参另留一个纯夜间 test 集用于最终验收这样不会被过拟合到某一类光照条件。4.3 夜间样本筛选把过曝、雨雾和空图挑出来训练前值得花二十分钟做一轮自动筛选。夜间数据的质量差异比白天大得多同一批数据里可能混着三种典型问题一是过曝帧车灯直射导致画面一大片高亮目标边界完全丢失二是雨雾或镜头水渍帧整幅图对比度极低三是完全无目标的空背景帧这类图片对训练检测器没有正向作用但会让评估阶段的分母变大拉低召回率。一个简单脚本按亮度方差和平均像素挑出可疑帧import numpy as np from PIL import Image from pathlib import Path THRESHOLD_VAR 150 THRESHOLD_BRIGHT 210 for img_path in Path(images).glob(*.jpg): gray np.array(Image.open(img_path).convert(L)) mean_brightness gray.mean() brightness_var gray.var() overexposed_ratio (gray THRESHOLD_BRIGHT).mean() if brightness_var THRESHOLD_VAR and mean_brightness 60: print(f[dark/rain] {img_path.name}, var{brightness_var:.1f}) elif overexposed_ratio 0.3: print(f[overexposed] {img_path.name}, over_ratio{overexposed_ratio:.2%})这里不是要把这些帧直接删除而是给他们打标签作为 test 集的困难子集。保留困难样本能让你看清模型边界删掉困难样本只有一种结果指标好看上线翻车。4.4 测试集该不该混白天样本一个务实建议如果你是做监控场景实际部署时摄像头 24 小时运转白天检测错误会拉低平均指标训练时完全不看白天帧也不行。我一般会把训练集分成两个池子白天池与夜间池白天池只参与预训练或主干 warmup夜间池负责主要训练验证集保留一部分白天样本做参照test 则完全切成纯夜间。这样得出的 mAP 反映的就是该数据集的真实夜间能力不会因为白天样本简单而虚高。5. 夜间场景训练避坑与排查5 条高频翻车记录5.1 现象白天验证集 mAP 不错夜间测试集直接腰斩白天验证集 mAP 在 0.8 以上换到夜间测试集掉到 0.5 甚至更低。原因几乎都是数据分布不一致白天样本与夜间样本在亮度、对比度、光源位置上差距太大模型只是学会了白天的特征组合。解决这件事的基础是数据层面先把纯夜间子集单独评估不要混着算总账再看网络输入分辨率夜间小目标需要更高的输入尺寸把训练分辨率从 640 提升到 1280 往往比换网络结构更有用同时训练时长要适当延长夜间特征更难收敛。5.2 现象黑色车几乎全漏检聚类增强也没用夜间黑色车与暗色道路融为一体人眼都难辨认。如果评估结果里黑色车召回率低于 30%说明模型学到的是亮度特征而不是车体轮廓。解决方向是给黑色车少量扩样本并单独算黑色车的召回率指标同时把训练输入从 RGB 换成带亮度归一化的预处理或者增加对比度拉伸的数据增强让黑色车在暗背景里被拉出一点边缘信息。增强参数不宜过猛否则整车变得过亮与实际场景更不匹配。5.3 现象路灯、反光标记、弯道指示牌被当成车检出来夜间误检与白天误检完全不同风格白天误检多为树影、井盖夜间误检集中出现在高亮小目标上路灯、反光标记、车道线反光点都会给出高置信度。原因是模型把「小区域高亮」学成了「车辆特征」尤其当训练数据里大量车辆框刚好包含车灯高亮区。解决路径有三层训练时保留 difficult 框并合理过滤后处理把检测框内的平均亮度与周边背景亮度做对比过滤纯发光物体通常中心和背景亮度差极大把验证集里误检框裁剪出来做一次聚类按宽高比过滤掉极窄或极小的高亮目标。5.4 现象远光灯车辆被标成两个高度重叠的框前车开远光时视觉上会出现两个高亮中心一个是车灯本体区域一个是地面或雾气上的光晕反射。模型同时检出两个框且彼此重叠NMS 抑制后可能把真正的车身框干掉。解决是在后处理里对重叠框做一次“车灯合并”两个框 IoU 较高且其中一个框的宽高比接近车灯区域而非整车区域时保留面积更大的框并压低其置信度。训练阶段也可以对这类样本做人工复核确认标注框是否只框住车体而不是光晕。5.5 现象mosaic 增强把夜间的“夜味”拼没了mosaic 增强能把四张图拼成一张整体亮度和对比度被重新统计夜间图的暗部被白天图的亮部拉平均等效于削弱了夜间的特征分布。这属于增强与场景背离的典型问题现象是训练 loss 看起来正常但夜间测试集一直没有提升。解决方式是训练中期关闭 mosaic或者把 mosaic 概率降到 0.3 以下同时把亮度抖动的幅度调小避免把“夜晚”增强成“黄昏”。6. 用三张表验证「夜间能力」mAP、类别精召率与误检复盘6.1 做一张夜间专属验证表把纯夜间测试集单独拎出来逐类别计算 precision 与 recall而不是只盯着总 mAP。夜间数据集的类别不平衡往往比白天严重得多摩托车、自行车样本少又难检总 mAP 掩盖了这些类别掉点的事实。下面这段代码统计检测框内高亮像素占比用来区分模型到底是学到「车体轮廓」还是「发光体」import numpy as np from PIL import Image # 检测输出每行是 [x1, y1, x2, y2, score, class_id] boxes [] for x1, y1, x2, y2, score, class_id in boxes: crop np.array(Image.open(frame_path).convert(L))[y1:y2, x1:x2] over_180 (crop 180).mean() print(fclass{class_id} score{score:.2f} bright_ratio{over_180:.2f})真车框的 bright_ratio 一般在 0.3 到 0.7 之间因为车灯高亮但车身不该整体过亮路灯、反光标牌这类误检框通常超过 0.8。这个指标可以做一个后处理开关不过要小心黑色车框的 bright_ratio 会很低不能一刀切。6.2 三张表完成夜间能力闭环验证第一张是逐类别的 precision/recall 表重点看摩托车和行人这种小目标第二张是误检框的高亮像素占比分布表确认误检来源到底是发光体还是背景纹理第三张是难点子集对照表把过曝、雨雾、远光灯三类样本单独评估。我现在拿到任何标注好的夜间数据集都会先跑这套验证流程再决定要不要继续加训练样本而不是直接换模型结构。模型结构的选择在夜间场景里远不如数据质量敏感把这三张表跑完你面对「夜间效果不好」这句话时至少能把它精确成「摩托车召回偏低」或「路灯误检偏多」而不是一句无从下手的玄学。有一次我在这张表里发现某类误检全部集中在远光灯直射帧最后只是把曝光预处理改了一下夜间 mAP 就升了五个点省掉了整整一个周末的调参。希望这套验证思路帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站