简介:面向自动驾驶车路协同领域的研究者与学习者,这份轻量级HTML项目源码围绕DAIR-V2X数据集进行完整讲解,重点解决首次接触该数据集时“如何下载解压、目录结构如何组织、标注格式与坐标变换怎样理解”等实际问题。DAIR-V2X是首个大规模多模态多视角车路协同数据集,涵盖2D/3D标注,由协同(C)、路端(I)、车端(V)三个子集构成,源码对其中DAIR-V2X-C子集展开细化,梳理数据获取流程、文件目录层级、标注文件内容及坐标变换信息,并提供百度网盘下载入口说明,帮助读者快速建立整体认知。资源包共2个文件,以HTML页面与inscode脚本为主,整体大小仅4KB,打开即可阅读,无需额外环境即可查看数据集的梳理结果。目前已有353人学习下载,适合需要系统了解DAIR-V2X数据结构、为后期协同感知实验与模型训练作准备的入门及进阶用户。
1. DAIR-V2X:多一个路侧视角,多一套坐标系问题
做单车感知的工程师第一次拿到DAIR-V2X,通常会把它当成一个加了路侧摄像头的KITTI来用:图像、激光雷达bin、KITTI风格标签,目录一换就能训练。真跑起来才发现,车端和路侧端各带一套外参,两套点云的坐标系原点根本不在同一个地方,缺了标定关系,协同数据就是一堆对不齐的黑匣子。DAIR-V2X的价值并不只是“多了一路数据”,而是把同一段路两套视角的传感器数据、3D标注、外参和时序放在一起,逼着你去解决协同感知里最实际的问题:把两个视角对齐到同一个坐标系,再决定怎么融合。这篇笔记面向想快速上手DAIR-V2X、做协同检测或车路融合算法的开发者,从目录结构讲到加载脚本、坐标变换和踩坑记录,最后给一个值得先做的验证技巧。
2. 读懂数据集构成:vehicle-side、infrastructure-side与它们各自的标注
2.1 两种视角的数据组织方式
DAIR-V2X最明显的特点,就是同一个场景被拆成 vehicle-side 和 infrastructure-side 两个视角来录制。vehicle-side 是装在测试车上的传感器采集的数据,场景跟着车走;infrastructure-side 是路侧固定杆位上摄像头和激光雷达采集的数据,覆盖一个固定的路口或路段。车端和路侧并不总是同时看到同一个目标,很多目标对车来说是盲区,对路侧来说完全无遮挡。这也是这个数据集的真正价值所在:它能让你训练融合模型时,有真实可用的“补盲”数据,而不是两路数据互相重复。
目录上一般会分成 train、val,内部再按 vehicle-side、infrastructure-side、cooperative 组织。cooperative 目录存放的是车路两侧已经做过时空对齐的协同帧,也就是论文里最常见的协同检测输入。下面是一个简化后的目录示意,具体命名以你实际拿到的压缩包为准:
DAIR-V2X/ ├── train/ │ ├── vehicle-side/ │ │ ├── image/ │ │ ├── velodyne/ │ │ ├── calib/ │ │ └── label/ │ ├── infrastructure-side/ │ │ ├── image/ │ │ ├── velodyne/ │ │ ├── calib/ │ │ └── label/ │ └── cooperative/ │ ├── vehicle-side/ │ └── infrastructure-side/ ├── val/ │ ├── vehicle-side/ │ ├── infrastructure-side/ │ └── cooperative/ └── test/image 目录存的是摄像头原始图像,velodyne 目录存的是激光雷达点云,calib 目录存传感器标定参数,label 目录存3D标注。这四类文件在同一视角内部靠文件名一一对应,比如一张名为000123.jpg的图像,对应000123.bin的点云和000123.txt的标签。文件名是第一条对齐线索,但它只保证同一个视角内部对应,不保证车端和路侧同名文件在时间上真的同步。
一个容易忽略的点是:并不是每个目录下的所有帧都有完整四件套。车端或者路侧因为传感器丢帧、采集异常,有的帧只有图像没有点云,有的帧连标注都没有。加载的时候按文件名索引去读,读不到就跳过,是最稳妥的做法。不要假设每一帧都是齐全的,这个数据集里“缺件”是正常现象。
2.2 标签文件与KITTI格式的差异点
DAIR-V2X的标签文件整体上是KITTI风格,每一行标注一个3D目标。字段大致是:类别、截断程度、遮挡程度、观测角度,然后是2D框(x1, y1, x2, y2)、3D框尺寸(高、宽、长)、3D框中心位置(x, y, z)和绕Y轴的朝向角rotation_y。看到这个结构,做过KITTI的人基本能无缝迁移,但有两个地方要和纯KITTI区分开。
第一,类别集合不完全一样。KITTI里常见的 Pedestrian、Cyclist 在路侧视角下出现频率更高,而且车路协同场景里还会有一些KITTI很少出现类别,比如卡车、拖车、锥桶。训练时不要硬套KITTI的类别处理逻辑,先统计一下训练集标签里的类别分布,再把出现次数过少的类合并到背景,否则模型很容易在某个稀有类别上疯狂漏检。
第二,x、y、z这3D中心坐标是在相机坐标系下的表述,不是BEV平面坐标,更不是点云的雷达坐标。KITTI惯例里z轴指向相机前方,x轴指向右,y轴指向下。如果你直接把标签里的x、y当成BEV的横纵坐标去画图,你会发现所有框都在地图上竖直排列,看起来完全不对。需要先做一次从相机坐标到雷达坐标的变换,或者按 z 作为前向、-x 作为横向、y 朝下这套约定重新解释。
Car 0.00 0 1.50 520.1 410.0 710.3 452.8 1.55 1.82 4.62 421.0 412.3 10.4 -1.57这一行表示一辆车,截断为0、遮挡为0、观测角1.50弧度,2D框左上角(520.1, 410.0)、右下角(710.3, 452.8),3D框高1.55米、宽1.82米、长4.62米,中心在相机坐标系(421.0, 412.3, 10.4),绕Y轴旋转-1.57弧度。其中rotation_y为负值表示车头相对于相机光轴偏转的方向,这个值在转BEV框的时候尤其容易出错,放到后面的避坑章节细说。
2.3 三个子任务怎么复用同一份标注
DAIR-V2X官方把数据使用划分成三个方向:协同检测、协同跟踪、协同预测。它们共用同一份底层数据,只是对标签的组织方式不同。协同检测吃的是单帧的车端点云加路侧点云,输出这帧里所有目标的3D框;协同跟踪吃的是连续多帧的检测结果,需要把同一个目标串起来,所以标签里必须有稳定的目标ID;协同预测吃的是跟踪得到的历史轨迹,输出目标未来几秒的位置,所以需要的至少是连续一段时间的坐标序列。
在标签层面,唯一的区别主要体现在是否使用 track_id 字段。检测任务可以完全忽略目标ID,跟踪任务则必须用它来关联跨帧目标,预测任务更进一步,通常要先拿track_id把历史轨迹切成一条一条的序列,再训练轨迹预测网络。很多人一上来直接做预测,拿单帧标签去凑历史序列,结果发现目标ID对不上,轨迹断裂严重。
| 子任务 | 输入 | 主要输出 | 关键字段 |
|---|---|---|---|
| 协同检测 | 车端点云 + 路侧点云/图像 | 3D框(类别、尺寸、朝向) | dims、location、rotation_y |
| 协同跟踪 | 连续帧检测结果 | 带track_id的检测序列 | track_id、location |
| 协同预测 | 历史轨迹 | 未来轨迹坐标 | track_id、连续帧x/y/z |
做协同检测时,train、val里的单帧样本就够用;做跟踪和预测,你还需要把连续帧按场景ID重新组织起来。这里建议先按场景ID建索引,再按时间戳排序,而不是依赖文件名排序,因为文件名顺序不等于时间顺序。我一般会先跑一个数据检查脚本,统计每个场景ID下的帧数分布,把帧数过短或者时间戳不连续的序列单独剔除,这种序列做跟踪预测基本是负收益。
3. 最小加载器:从目录到图像、点云和标定的20行代码
3.1 下载解压后先做一件事:建立文件索引
解压之后先别急着训练,做一次文件索引检查,把所有缺文件、缺标注、时间戳对不上的样本列出来。这一步看着琐碎,但能省掉后面好几个通宵的排查时间。常见的做法是写一个脚本遍历四个子目录,按文件名把图像、点云、标定、标签配对,再把配对结果保存成一份 JSON 清单。后续所有数据加载器都读这份清单,而不是直接扫文件系统,避免每次换实验都要重新遍历一遍。
import json from pathlib import Path def build_index(root: Path): index = [] for view in ["vehicle-side", "infrastructure-side"]: img_dir = root / "train" / view / "image" for img_path in sorted(img_dir.glob("*.jpg")): stem = img_path.stem pcd_path = img_path.parent.parent / "velodyne" / f"{stem}.bin" calib_path = img_path.parent.parent / "calib" / f"{stem}.txt" label_path = img_path.parent.parent / "label" / f"{stem}.txt" if not (pcd_path.exists() and calib_path.exists()): continue index.append({ "view": view, "image": str(img_path), "pointcloud": str(pcd_path), "calib": str(calib_path), "label": str(label_path) if label_path.exists() else None, }) return index root = Path("/data/DAIR-V2X") index = build_index(root) with open("index.json", "w", encoding="utf-8") as f: json.dump(index, f, indent=2)这里只保留了点云和标定都存在的样本,标签文件允许不存在,因为负样本帧没有目标但没有标签文件的情况是存在的。索引结构里保留了 view 字段,方便后面判断这帧数据来自车端还是路侧,cooperative 的配对帧另做一张表,不混在一起。这样两个加载逻辑互相独立,排查问题的时候边界也清楚。
参数说明:root 指向数据集根目录,view 拼接时用了相对路径img_path.parent.parent,它依赖固定的目录层级“image 的上一级是视角目录,再上一级是train”。如果你解压后调整过目录结构,这段逻辑就会失效,建议把层级关系也做进配置里。
3.2 读点云、读图像、读标签的最小实现
点云文件是二进制格式,每四个 float 组成一个点,分别是 x、y、z、intensity。读取时用 numpy 的 fromfile 即可,不需要额外依赖。图像用 OpenCV 读取,但要留意通道顺序是 BGR,转成 RGB 再给模型用。标签文件按行解析,每一行按空格切开转 float,再按列位置塞进一个结构体即可。
import cv2 import numpy as np def load_pointcloud(bin_path: str) -> np.ndarray: raw = np.fromfile(bin_path, dtype=np.float32) return raw.reshape(-1, 4) def load_image(img_path: str, to_rgb: bool = True) -> np.ndarray: img_bgr = cv2.imread(img_path, cv2.IMREAD_COLOR) if to_rgb: return cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) return img_bgr def load_label(label_path: str): if label_path is None or not Path(label_path).exists(): return [] boxes_3d = [] with open(label_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() if len(parts) < 15: continue boxes_3d.append({ "type": parts[0], "truncation": float(parts[1]), "occlusion": float(parts[2]), "alpha": float(parts[3]), "bbox_2d": [float(v) for v in parts[4:8]], "dims_3d": [float(v) for v in parts[8:11]], # h, w, l "location": [float(v) for v in parts[11:14]], # x, y, z "rotation_y": float(parts[14]), }) return boxes_3d这段代码的关键点在于:点云 reshape 成 N×4 而不是 N×3,是因为二进制里每个点都带强度信息,丢掉强度会导致后续数据长度错位;标签解析时对短行做了保护,因为有些行可能是空行或者注释行,直接按空格切会越界。bbox_2d 和 dims_3d 里的顺序不能换,后面所有几何计算和可视化都要依赖这个顺序。
加载函数返回的都是原始数据,没有做坐标系转换,真正的转换放到数据预处理或者模型输入阶段。这样做的理由是标定和坐标系处理应该集中在同一个模块里,每个数据加载器都去改坐标,很容易改出一个互相矛盾的版本。
3.3 标定文件:内容、顺序和常见误解
标定文件是整个协同感知的命脉,它定义了图像、雷达、全局坐标三者之间的关系。DAIR-V2X 的 calib 文件延续了KITTI风格,通常包含相机内参矩阵、相机外参、雷达到相机的变换矩阵。第一次接触的人最容易搞混的是 Tr_velo_to_cam 的方向:它是从雷达坐标系变换到相机坐标系的,还是反过来的,决定了点云投影到图像时的乘法顺序。
P2: 684.5 0.0 623.4 0.0 0.0 684.5 348.2 0.0 0.0 0.0 1.0 0.0 Tr_velo_to_cam: 0.003 -0.999 0.017 0.081 0.999 0.003 -0.042 -0.140 0.042 -0.017 -0.998 0.141 0.0 0.0 0.0 1.0 R0_rect: 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0P2 是相机内参矩阵,3×4 的最后一位常为0,投影时与相机坐标系下的点做齐次乘法。Tr_velo_to_cam 是雷达到相机的刚体变换矩阵,前3×3是旋转,第4列是平移。R0_rect 是相机坐标系内的校正旋转矩阵,对单个摄像头来说通常近似单位阵,但在多摄系统中它不能省略。整个投影链路是:雷达点先经 Tr_velo_to_cam 到相机坐标,再经 R0_rect 校正,最后用 P2 投影到像素平面。
实际使用里我见过两种坑:一种是有人把 Tr_velo_to_cam 的转置直接拿去乘,结果点云全飞到画面角落;另一种是把 R0_rect 当单位阵直接跳过,如果这个数据集里 R0_rect 不是单位阵,点云和图像会出现固定偏移。建议拿到数据后先把这个矩阵打印出来看一眼,确认是不是近似单位阵,再决定能不能简化。
def project_velo_to_image(points: np.ndarray, calib: dict): # points: N x 4 (x, y, z, intensity) velo = calib["Tr_velo_to_cam"] rect = calib["R0_rect"] p2 = calib["P2"] pts_h = np.hstack([points[:, :3], np.ones((len(points), 1))]).T # 4 x N cam = velo @ pts_h # 变换到相机坐标系 cam = rect[:3, :3] @ cam[:3, :] cam = np.vstack([cam, np.ones((1, cam.shape[1]))]) uv = p2 @ cam # 投影到像素坐标 uv = uv[:2, :] / uv[2, :] # 归一化 return uv.T # N x 2 像素坐标这段代码把雷达点逐点投影到图像平面。乘法顺序严格按 雷达→相机→校正→投影 走,任何一步顺序调换,投影结果都会不对。投影结果可以用来做传感器一致性验证,也可以用来生成图像视角的深度图,是后续很多可视化工作的基础。
4. 搭协同检测baseline:坐标对齐、真值融合与mAP计算
4.1 把路侧点云变换到车载坐标系:一个矩阵就能对齐
协同检测的输入是两套点云,但它们各自的原点不同:车端点云原点在车身上,路侧点云原点在路侧杆位上。要做融合,必须先把路侧点云换到车端坐标系。常见做法是利用标定文件提供的两套外参,统一到一个全局坐标系下,再转到车端。这个变换链写成矩阵就是:先对路侧点云做路侧到全局的变换,再做全局到车端的逆变换。
def align_infra_to_vehicle(infra_points: np.ndarray, T_infra_global: np.ndarray, T_veh_global: np.ndarray) -> np.ndarray: # 全局到车端 = 车端全局位姿的逆 T_global_to_veh = np.linalg.inv(T_veh_global) T_infra_to_veh = T_global_to_veh @ T_infra_global pts_h = np.hstack([infra_points[:, :3], np.ones((len(infra_points), 1))]) aligned = (T_infra_to_veh @ pts_h.T).T return aligned[:, :3]这里的核心是 T_infra_to_veh 的构造。T_infra_global 从路侧的标定文件里读,T_veh_global 从车端的标定文件里读,两者必须来自同一套全局坐标系定义。如果数据集里提供的是相机坐标系的标定,你得先把相机外参转成雷达坐标系下的刚体变换,再做复合。这一步出错很隐蔽,因为点云形状整体看起来没问题,位置偏移可能只有几十厘米,但在检测边界框上会直接导致漏检。
我自己习惯在跑训练之前,抽样三五个协同帧,把对齐后的路侧点云和车端点云一起画在BEV图里看。检查点很简单:同一辆车在两套点云里应该是重叠的,如果出现重影,说明外参变换链有误差;如果完全错开,说明有一个矩阵的方向搞反了。这种可视化检查最多花半小时,但能避免整个训练周期白跑。
4.2 真值融合:到底以谁的标准框为准
数据有了,标签有了,模型该按什么标准学,是协同检测里绕不开的问题。车端有车端的3D标注,路侧有路侧的3D标注,两边对同一个目标的框往往存在细微差异:路侧视角高,框的高度和朝向更准;车端视角近,近距离目标的位置更准。如果直接把两套标签都当成真值去监督模型,模型会在一个目标上看到两个不一致的目标框,loss 来回震荡,收敛困难。
一个我常用的策略是:以车端标签为主,对某类目标车端没有标注而路侧有时,再补充路侧标签。操作上先做标签匹配,通过3D IoU 判断两边的框是否指向同一个目标,IoU 大于0.5视为匹配,不匹配的部分单独处理。匹配上的框取两边标注的均值作为融合真值,或者直接取车端标注,因为自动驾驶系统的最终决策发生在车端坐标系,模型输出对齐车端标准,下游模块改动最小。
def match_labels(veh_boxes, infra_boxes, iou_thresh=0.5): matches = [] for i, vb in enumerate(veh_boxes): best_j, best_iou = -1, 0 for j, ib in enumerate(infra_boxes): iou_val = compute_iou_3d(vb, ib) if iou_val > best_iou: best_iou = iou_val best_j = j if best_iou >= iou_thresh: matches.append((i, best_j)) return matches这个匹配结果的用途有两个:一是决定训练时真值框取哪边的,二是统计车端和路侧标注的系统性偏差。如果偏差过大,比如路侧框普遍比车端框大10厘米以上,说明两边的标注规范不一致,训练前要先做尺寸统一。常见处理是把匹配对的高、宽、长分别取中位数比值,然后做刚性缩放,把路侧框调整到与车端框同一尺度。
4.3 评价指标:BEV视角下的3D mAP计算逻辑
协同检测的指标通常用3D mAP,但很少有人一上来就计算完整的3D IoU。更稳的做法是先在BEV平面算2D IoU,也就是把每个3D框投影到鸟瞰平面,算水平面上的面积重叠。原因是垂直方向的误差对框的俯仰角非常敏感,而BEV视角的IoU更能反映“目标有没有被正确框住”这个核心问题,也更容易排查错误。
计算mAP时需要定义匹配阈值。业内做协同检测普遍用0.7的BEV IoU阈值,比单视角检测的0.5更严格,因为协同感知理论上能提供更精确的空间信息。阈值设太高会看不到模型进步,设太低会让真值融合的粗差被掩盖。建议实验初期用0.5和0.7各跑一版,对比阈值敏感度再决定正式指标。
def compute_bev_iou(box_a, box_b): # box: (x, z, w, l, yaw),忽略y轴即高度方向 corners_a = corners_from_bev(box_a) corners_b = corners_from_bev(box_b) inter_area = polygon_intersection_area(corners_a, corners_b) union_area = polygon_area(corners_a) + polygon_area(corners_b) - inter_area return inter_area / union_area这个函数的输入是已经转换到BEV坐标系的3D框,yaw是绕垂直轴的旋转角。计算时先用旋转矩生成四个角点,再求两个多边形的交集面积,最后算IoU。整个过程不涉及高度信息,所以框的z值误差和高度误差不会干扰匹配,这也符合协同检测里先定位后定高的直觉。
别忘了在算mAP之前先做类型的对齐。数据集类别集合和模型预测类别集合通常不完全一致,标签里出现模型没定义的类别时,要先映射到最近的父类别或者过滤掉,否则precision-recall曲线会被一类错误全部拖垮。
5. 避坑指南:DAIR-V2X使用中的5个典型问题
5.1 现象:训练到一半协同帧全部被跳过
加载器静默跳过,loss 偶尔出现断崖上升,检查发现每个 step 实际只喂了车端单视角的数据。原因是我在构建协同训练集时,用一个循环去读取车端和路侧的文件,遇到路侧文件缺失时直接 continue,但没做计数统计,直到训练日志里没有一条包含路侧点云的 loss 才意识到问题。解决方式是给加载器加一个样本有效性检查函数,帧不完整时记录原因并统计,训练启动时先打印各类样本占比,车端、路侧、协同各是多少,一旦发现协同样本为0立刻终止。
5.2 现象:点云投影到图像上大面积偏移,但姿态看起来正常
点云整体形状是对的,目标轮廓也对,但整体偏移了十几个像素。原因是投影链路里 R0_rect 被直接跳过了,而这个数据集里的 R0_rect 虽然接近单位阵,对角线项误差累积起来足够造成明显位移。解决方法是把投影链路完整跑一遍,不要为了省几个矩阵乘法省略任何一步,同时用print(calib["R0_rect"])检查数值,确认它到底接近单位阵还是非单位阵。
5.3 现象:yaw角换算到BEV框时框是斜的或反向的
标签里 rotation_y 是正的,画出来却朝后;rotation_y 是负的,画出来却朝左。这个问题的根源是KITTI风格的 rotation_y 定义在相机坐标系下,绕y轴正向旋转,而 BEV 视角里一般用绕z轴或绕垂直轴的朝向角。转换时如果没有把坐标系轴重映射,直接拿 rotation_y 当 BEV 朝向角用,结果必乱。解决方式是先把3D框角点从相机坐标转换到雷达坐标,再在雷达坐标下计算朝向角,而不是直接改 rotation_y 的符号。
def rotation_y_to_bev(rotation_y: float) -> float: # 相机坐标:x右 y下 z前 # BEV坐标:x前 z右 的约定下,需要重新映射 return np.pi / 2 - rotation_y这个式子只适用于相机坐标系到某个固定BEV约定的映射。具体系数要看你定义的输出坐标系,建议用一个真实框做可视化验证:把3D框角点画进BEV图里,和点云形状对比,确认框的方向和车辆车头方向一致。
5.4 现象:验证集mAP比训练集高出一大截
模型正常收敛,训练集mAP也正常,但验证集分数异常高。排查发现验证集里车端和路侧的标定文件来自不同录制批次,外参基准不完全统一,导致模型在验证时恰好对齐得更好,属于数据划分时没按场景ID隔离输入的泄漏。解决方式是按场景ID划分训练验证集,而不是按文件名随机切分,同时确认同一个场景的所有帧只出现在一个集合里。
5.5 现象:显存占用不高但batch size加不上去
点云加载和预处理在GPU训练循环里同步执行,numpy的数组切片和坐标变换把CPU卡满,GPU一直等数据。解决方式是改成异步加载,用torch.utils.data.DataLoader的num_workers加大进程数,同时把坐标变换挪到 collate_fn 或者 on-the-fly 预处理里,避免在 Dataset 里做重计算。point cloud pillar 化或者体素化也在数据加载阶段提前完成,不要让GPU等CPU做几何计算。
6. 进阶验证:先过投影回环,再做融合
6.1 用3D框重投影量化标定误差
模型训练前,我会挑出20帧标注质量较高的样本,把标签里的3D框角点投影到图像上,看是否与图像里的目标边缘贴合。这个方法不需要额外工具,只需要投影脚本和人工肉眼检查。误差小于5个像素说明标定链路和坐标变换正确,超过15个像素就要回头查外参复合矩阵。
6.2 用强度图做细对齐
点云投影到图像上形成稀疏深度图,把每个像素的深度值渲染成伪彩色,与图像叠加观察。如果物体边缘处深度跳变与图像边缘一致,说明时间同步和空间标定都基本可靠。这个检查和3D框投影配合使用,一个查几何关系,一个查时序一致性,两者都过了再进入模型训练。
6.3 验证通过后再谈融合策略
很多人在标定还没确认时就急着试不同融合网络,最终结论往往被标定误差带偏。我习惯先花两天时间把数据检查和投影验证做扎实,确认所有坐标系链路一致,再开启融合实验。这样一来,模型指标变差能从算法层面找原因,而不是在数据链路里猜问题。这一类数据集的坑点高度相似,大部分时间其实花在数据对齐上,而不是模型结构上。
每次换新设备或者新数据集,我都会强制自己走一遍投影回环这个流程,虽然看起来费时间,但它能绕开大量“玄学”调参。如果你刚拿到DAIR-V2X,建议也从这个验证开始做起,先把坐标系理清楚,再谈模型效果。希望帮到你。
本文还有配套的精品资源,点击获取