简介:这份Python源码面向计算机视觉与自动驾驶方向的开发者,聚焦nuScenes数据集中3D标注到2D图像的转换问题。资源围绕坐标系变换、相机投影、畸变校正以及3D边界框顶点映射等关键环节展开,帮助读者理解如何将激光雷达等传感器中的三维检测结果准确落到摄像头图像平面上,适用于算法验证、数据可视化与感知结果分析等场景。压缩包共2个文件,均为py脚本,整体约4KB,分别承担2D边界框绘制与整体流程串联的职责,结构轻量、便于直接阅读和二次修改。目前已有2646人学习下载,说明该实现具备一定的参考价值。通过运行与调试这些代码,读者可以掌握从世界坐标系到相机坐标系、再到像素坐标的完整转换链路,并借助OpenCV完成边界框绘制,为自动驾驶感知算法的开发与调试提供可复用的实践基础。
1. 从 nuScenes 的 3D 框到 2D 框:为什么你的标注投影总对不上
做过自动驾驶感知的人多半碰过这个场景:手头有一份 nuScenes 数据集,想拿它训练 2D 检测器,或者只想把 3D 标注框可视化出来核对一下,结果把 3D box 往图像上一投,框要么偏出目标几十个像素,要么整个跑到画面外。这不是玄学,是坐标系没对齐。nuScenes 的 3D 标注活在激光雷达和全局坐标系里,而图像是相机坐标系下的针孔投影结果,中间隔着外参、内参、畸变、时间戳同步四道关。所谓「nuScenes 数据集 3D 转 2D python 源码」,本质就是写一套把 3D 框的八个角点,经过传感器标定链路,投影到每张相机图上并取外接矩形的代码。它解决的是「3D 真值复用」问题,适合想用 nuScenes 做 2D 检测、做投影验证、做多模态对齐的从业者。下面我按自己踩过的顺序,把这条链路拆开讲透。
2. 先搞懂 nuScenes 的坐标系链路,再动手写投影
投影对不上,九成是坐标系没理清。nuScenes 里一个 3D 框的坐标不是直接给相机的,它要经过「全局 → 自车 → 传感器 → 相机 → 像素」这条链。很多人一上来就套K @ [R|t],结果忽略了自车坐标系和全局坐标系之间的位姿,框自然飘。
2.1 四个坐标系和它们之间的变换关系
nuScenes 的标注框(sample_annotation)给的是全局坐标系下的translation、size、rotation。全局坐标系是那个大范围的地图坐标系,自车坐标系(ego pose)是车辆自身位置,传感器坐标系是每个相机、激光雷达自己的原点。相机坐标系是传感器坐标系的一种,但方向定义不同:相机看向 +Z,而激光雷达常见看向 +X。
变换关系是:全局到自车用ego_pose,自车到传感器用calibrated_sensor,传感器到相机再套相机自身的旋转。这条链少一环,投影就错。我一般会先把每个 sample 的ego_pose和calibrated_sensor都取出来,确认时间戳一致,再往下算。
2.2 3D 框的八个角点怎么算出来
nuScenes 的框是中心点加尺寸加四元数旋转。要投影,得先把它展开成八个角点。尺寸是[width, length, height],注意顺序,nuScenes 里 width 对应 x,length 对应 y,height 对应 z。旋转用四元数[w, x, y, z],转成旋转矩阵后作用到角点上。
import numpy as np from pyquaternion import Quaternion def box_to_corners(center, size, rotation): # center: [x, y, z], size: [w, l, h], rotation: 四元数 [w, x, y, z] w, l, h = size # 八个角点在框本地坐标系下的偏移 x_corners = [w/2, w/2, -w/2, -w/2, w/2, w/2, -w/2, -w/2] y_corners = [l/2, -l/2, -l/2, l/2, l/2, -l/2, -l/2, l/2] z_corners = [h/2, h/2, h/2, h/2, -h/2, -h/2, -h/2, -h/2] corners = np.array([x_corners, y_corners, z_corners]) # 3x8 # 旋转矩阵 rot = Quaternion(rotation).rotation_matrix corners = rot @ corners corners += np.array(center).reshape(3, 1) return corners # 3x8,全局坐标系这段代码的关键在角点顺序和旋转矩阵的作用方式。Quaternion的rotation_matrix返回 3x3,直接左乘本地角点,再加中心点,得到全局坐标。参数上,size的顺序千万别搞反,nuScenes 文档里写的是 width、length、height,但很多人按 length、width、height 写,框就会横竖颠倒。
2.3 从全局到相机的完整变换矩阵
拿到全局角点后,要把它转到相机坐标系。步骤是:全局 → 自车 → 传感器 → 相机。每一步都是一个 4x4 的变换矩阵。自车到全局的逆是ego_pose的逆,传感器到自车的逆是calibrated_sensor的逆,相机到传感器的逆是相机标定里的旋转平移。
def global_to_camera(corners, ego_pose, calib_sensor, cam_intrinsic): # corners: 3x8 全局坐标 # ego_pose: dict,含 translation 和 rotation # calib_sensor: dict,含 translation 和 rotation # 全局 -> 自车 ego_t = np.array(ego_pose['translation']) ego_r = Quaternion(ego_pose['rotation']).rotation_matrix # 自车 -> 传感器 cs_t = np.array(calib_sensor['translation']) cs_r = Quaternion(calib_sensor['rotation']).rotation_matrix # 组合:先减自车平移,再转,再减传感器平移,再转 corners_ego = ego_r.T @ (corners - ego_t.reshape(3, 1)) corners_sensor = cs_r.T @ (corners_ego - cs_t.reshape(3, 1)) # 传感器 -> 相机,nuScenes 相机坐标系需要额外旋转 # 常见做法是套一个从传感器到相机的固定旋转 cam_rot = np.array([[0, -1, 0], [0, 0, -1], [1, 0, 0]]) # 示例,按实际标定调整 corners_cam = cam_rot @ corners_sensor return corners_cam这里cam_rot是个容易翻车的地方。nuScenes 的相机传感器坐标系和标准相机坐标系之间有一个固定旋转,不同版本、不同相机可能不一样。我一般会先用一个已知的框去验证,如果投影出来上下颠倒,就是旋转矩阵的符号问题。参数上,ego_pose和calib_sensor的rotation都是四元数,顺序是[w, x, y, z],别当成[x, y, z, w]。
2.4 内参和畸变:最后一步别漏了
相机内参cam_intrinsic是 3x3 矩阵,把相机坐标系下的点投影到像素。公式是u = fx * X/Z + cx,v = fy * Y/Z + cy。但 nuScenes 的图像有畸变,尤其是鱼眼相机,直接投会偏。常见做法是用cv2.projectPoints带上畸变系数,或者先做去畸变再投。
import cv2 def project_to_image(corners_cam, cam_intrinsic, dist_coeffs=None): # corners_cam: 3x8 # 只保留 Z > 0 的点,否则在相机后面 valid = corners_cam[2, :] > 0 if not np.any(valid): return None pts = corners_cam[:, valid].T.reshape(-1, 1, 3) if dist_coeffs is not None: img_pts, _ = cv2.projectPoints(pts, np.zeros(3), np.zeros(3), cam_intrinsic, dist_coeffs) else: img_pts = cv2.projectPoints(pts, np.zeros(3), np.zeros(3), cam_intrinsic, None)[0] img_pts = img_pts.reshape(-1, 2) # 取外接矩形 x_min, y_min = img_pts.min(axis=0) x_max, y_max = img_pts.max(axis=0) return [x_min, y_min, x_max, y_max]cv2.projectPoints的dist_coeffs传 None 就是无畸变模型。nuScenes 的相机畸变系数在calibrated_sensor里,通常是 5 个参数。注意projectPoints的输入是 Nx1x3,输出是 Nx1x2,reshape 别搞错。取外接矩形时,如果框有一部分在相机后面,那些点要丢掉,否则会得到负的 Z 导致投影爆炸。
3. 用 nuScenes devkit 跑通最小投影脚本
理论理清了,落到代码。nuScenes 官方有 devkit,但很多人不想装全套,我一般会直接读 JSON 或者用 devkit 的nusc对象。下面是一个最小可跑脚本,从加载数据到输出 2D 框。
3.1 加载 sample 和对应的标注
先初始化 nuScenes 对象,拿到一个 sample,再取它的所有 annotation 和对应的相机数据。
from nuscenes.nuscenes import NuScenes nusc = NuScenes(version='v1.0-mini', dataroot='/path/to/nuscenes', verbose=True) sample = nusc.sample[0] # 取第一个 sample ann_tokens = sample['anns'] cam_channels = ['CAM_FRONT', 'CAM_FRONT_LEFT', 'CAM_FRONT_RIGHT', 'CAM_BACK', 'CAM_BACK_LEFT', 'CAM_BACK_RIGHT']version和dataroot按实际填。v1.0-mini是迷你版,适合调试。sample['anns']是标注 token 列表,每个 token 对应一个 3D 框。相机通道名是固定的,别写错。
3.2 遍历相机并做投影
对每个相机,取它的calibrated_sensor和ego_pose,然后对每个标注做投影。
for cam in cam_channels: cam_data = nusc.get('sample_data', sample['data'][cam]) calib = nusc.get('calibrated_sensor', cam_data['calibrated_sensor_token']) ego = nusc.get('ego_pose', cam_data['ego_pose_token']) intrinsic = np.array(calib['camera_intrinsic']) dist = np.array(calib.get('camera_distortion', [])) or None for ann_token in ann_tokens: ann = nusc.get('sample_annotation', ann_token) corners = box_to_corners(ann['translation'], ann['size'], ann['rotation']) corners_cam = global_to_camera(corners, ego, calib, intrinsic) bbox = project_to_image(corners_cam, intrinsic, dist) if bbox: print(f"{cam}: {bbox}")这段代码把前面几个函数串起来。camera_intrinsic是 3x3 列表,转 numpy。camera_distortion可能不存在,用get加默认值。投影出来的bbox是[x_min, y_min, x_max, y_max],可以拿去画框。
3.3 把结果画到图像上验证
光打印不够,得画出来看。用 OpenCV 读图,画矩形,保存。
import os img_path = os.path.join(nusc.dataroot, cam_data['filename']) img = cv2.imread(img_path) if bbox: x1, y1, x2, y2 = map(int, bbox) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite(f'out_{cam}.jpg', img)filename是相对路径,拼上dataroot。画框时坐标要转 int。如果框画出来偏,先检查global_to_camera里的旋转矩阵,再检查内参。我一般会先用一个正前方的框去对,正前方对上了,其他方向基本没问题。
4. 投影链路里最容易翻车的五个坑
这条链路我调过不下十次,每次翻车都差不多是那几个地方。下面按「现象 → 原因 → 解决」列出来,你对着排查能省不少时间。
4.1 框整体偏移几十像素
现象:投影出来的框和目标差一个固定偏移,所有框都偏同一个方向。原因:内参的cx、cy用错了,或者图像做了裁剪但内参没更新。解决:核对camera_intrinsic里的cx、cy是否等于图像宽高的一半,如果不是,说明有裁剪或缩放,内参要同步调整。
4.2 框上下颠倒或左右镜像
现象:框的位置对,但方向反了。原因:传感器到相机的旋转矩阵符号错了。nuScenes 的相机坐标系定义和常见 OpenCV 坐标系有差异。解决:用一个已知框验证,如果上下颠倒,把旋转矩阵的第二行取反;如果左右镜像,第一行取反。别硬套网上的矩阵,以实际数据为准。
4.3 框跑到画面外或 Z 为负
现象:投影出来坐标是负数或者极大值。原因:角点里有 Z < 0 的点,即框有一部分在相机后面。解决:投影前过滤掉 Z <= 0 的点,只对可见部分取外接矩形。如果整个框都在后面,直接跳过。
4.4 鱼眼相机投影严重变形
现象:前视相机还行,鱼眼相机框歪得离谱。原因:没用畸变系数,或者畸变模型不对。解决:nuScenes 的畸变系数在calibrated_sensor里,用cv2.projectPoints时传进去。如果还不对,试试先cv2.undistort再去投影。
4.5 时间戳不同步导致框漂移
现象:静态场景还行,动态场景框跟不上目标。原因:sample_data的时间戳和sample_annotation的时间戳不一致,自车在动。解决:用sample_data的ego_pose而不是sample的,确保每个相机图对应它自己时刻的位姿。nuScenes 里每个sample_data都有自己的ego_pose_token,别偷懒用 sample 级别的。
5. 进阶:批量导出 2D 标注和几个提效技巧
单张跑通后,下一步是批量导出成训练格式。我一般会导成 COCO 或 YOLO 格式,方便直接喂给 2D 检测器。这里说几个提效的点。
5.1 批量导出 COCO 格式的 2D 框
COCO 格式需要images、annotations、categories三个字段。遍历所有 sample,对每个相机图生成一条 image 记录,对每个可见框生成一条 annotation。
import json coco = {'images': [], 'annotations': [], 'categories': [{'id': 1, 'name': 'vehicle'}]} ann_id = 1 for sample in nusc.sample: for cam in cam_channels: cam_data = nusc.get('sample_data', sample['data'][cam]) img_info = {'id': len(coco['images']) + 1, 'file_name': cam_data['filename'], 'width': 1600, 'height': 900} coco['images'].append(img_info) # 投影逻辑同上,略 for bbox in bboxes: coco['annotations'].append({ 'id': ann_id, 'image_id': img_info['id'], 'category_id': 1, 'bbox': bbox, 'area': (bbox[2]-bbox[0])*(bbox[3]-bbox[1]), 'iscrowd': 0 }) ann_id += 1 json.dump(coco, open('nuscenes_2d.json', 'w'))width和height按实际图像填,nuScenes 是 1600x900。bbox是[x, y, w, h],注意 COCO 的格式和前面的[x1, y1, x2, y2]差一个转换。类别按你的需求映射,nuScenes 有 23 类,可以合并。
5.2 用向量化加速批量投影
逐框循环在 mini 版还行,全量数据集会慢。我一般会把角点计算和投影都向量化,一次处理一个 sample 的所有框。
def batch_project(anns, ego, calib, intrinsic): all_corners = [] for ann in anns: all_corners.append(box_to_corners(ann['translation'], ann['size'], ann['rotation'])) corners = np.concatenate(all_corners, axis=1) # 3x(8N) corners_cam = global_to_camera(corners, ego, calib, intrinsic) # 后续投影按列分组处理 return corners_camnp.concatenate把多个框的角点拼在一起,一次做矩阵变换。投影时再按 8 个一组切分。这样比逐框循环快好几倍,全量数据集上差别明显。
5.3 验证投影正确性的两个土办法
没有绝对可靠的自动验证,我一般用两个土办法。一是选一个正前方、距离已知的框,手动算一下像素位置,和代码输出对。二是把投影框画到图上,肉眼看是否贴合目标。如果框和目标差得离谱,先查坐标系链路,再查内参。别一上来就怀疑数据有问题,九成是代码里的旋转或平移符号错了。
最后说个习惯:每次改完投影代码,我都会先用一个 sample 跑一遍,把六个相机的图都画出来看一遍,确认没问题再批量跑。这个习惯帮我省了很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取