简介:这份资源面向自动驾驶感知与计算机视觉方向的开发者,提供nuScenes数据集3D标注转2D图像坐标系的Python实现,解决激光雷达3D边界框投影到多视角相机图像上的工程问题。压缩包共2个文件,均为py脚本,体积约4KB,分别承担2D边界框绘制与整体流程串联的职责,便于直接阅读和二次修改。内容围绕世界坐标系到相机坐标系的转换、基于相机内参的透视投影、畸变校正以及3D边界盒顶点到2D框中心、尺寸与旋转角的计算展开,并涉及多传感器数据的整合逻辑。已有2646人学习,适合希望理解3D到2D投影原理、将检测结果可视化到图像上的读者参考,也可作为自动驾驶算法开发与数据分析的入门素材。
1. 从 nuScenes 的 3D 框到 2D 框:这份源码到底解决了什么
如果你手头正在跑 nuScenes 数据集,大概率遇到过这个场景:标注文件里给的是translation、size、rotation这些 3D 信息,但你的下游任务——比如 2D 检测、BEV 可视化、或者给图像打伪标签——需要的是像素坐标系下的[x_min, y_min, x_max, y_max]。手动算?一个样本 20 多个目标,6 个相机通道,算到第三个样本你就想砸键盘了。这份 nuScenes 数据集 3D 转 2D 的 Python 源码,干的就是把 3D 框投影到 6 个相机图像上、输出 2D 框坐标这件事。它适合正在做多模态融合、跨视角对齐、或者需要把 nuScenes 标注喂给 2D 检测器的从业者。我拆过不少类似脚本,这份的完整度算中上——投影逻辑、遮挡判断、边界裁剪都有涉及,但有几个参数不调对,出来的框能偏到姥姥家。
2. 投影链路拆解:从 ego 坐标系到像素坐标的完整变换
2.1 为什么不能直接拿 translation 乘内参
nuScenes 的标注框中心点translation是在全局坐标系(global frame)下的,而相机内参camera_intrinsic只负责相机坐标系到像素坐标系的映射。中间缺了两步:全局到 ego、ego 到相机。很多人第一次写投影代码,直接把translation乘内参矩阵,结果框全跑到图像外面去了——这不是玄学,是坐标系没对齐。
完整的变换链路是这样的:
global → ego → camera → image每一步对应一个变换矩阵。全局到 ego 用ego_pose的逆矩阵,ego 到相机用camera_calibration里的sensor2ego和ego2global组合求逆。源码里通常封装成一个get_transformation_matrix函数,把这几步串起来。我一般会先把单个样本的变换矩阵打印出来,确认数值范围合理再往下跑——比如 ego 到相机的平移量通常在几米以内,如果看到几百的量级,大概率是矩阵乘反了。
2.2 源码核心函数逐行说明
下面这段是投影计算的核心逻辑,我按自己的理解加了注释,参数含义在后面展开:
import numpy as np def project_3d_to_2d(box_3d, camera_intrinsic, ego2global, sensor2ego, ego2global_cam): """ box_3d: [x, y, z, dx, dy, dz, yaw] 全局坐标系下的3D框 camera_intrinsic: 3x3 内参矩阵 ego2global: 4x4 车辆到全局的变换矩阵 sensor2ego: 4x4 相机到车辆的变换矩阵 ego2global_cam: 4x4 相机时刻的车辆到全局变换 """ # 第一步:全局坐标转 ego 坐标 global2ego = np.linalg.inv(ego2global) box_ego = global2ego @ np.array([box_3d[0], box_3d[1], box_3d[2], 1.0]) # 第二步:ego 坐标转相机坐标 ego2sensor = np.linalg.inv(sensor2ego) box_cam = ego2sensor @ box_ego # 第三步:相机坐标转像素坐标(需要 z > 0 才在相机前方) if box_cam[2] <= 0: return None # 目标在相机后方,直接丢弃 pixel = camera_intrinsic @ box_cam[:3] u = pixel[0] / pixel[2] v = pixel[1] / pixel[2] # 第四步:用3D框的尺寸估算2D框的宽高(简化处理) # 实际源码里会用8个角点投影后取min/max dx, dy, dz = box_3d[3], box_3d[4], box_3d[5] # 这里省略了角点投影的完整展开,核心思路是8个角点分别投影 return u, v逻辑说明:第一步的global2ego求逆是关键,很多人忘了求逆直接乘,结果就是框的位置完全对不上。第二步的ego2sensor同理。第三步的z > 0判断是必须的——相机坐标系下 z 轴指向前方,z 小于等于 0 说明目标在相机后面,投影出来是镜像的假框。第四步的角点投影才是完整做法,源码里一般会写一个get_8_corners函数把 3D 框的 8 个顶点都算出来,分别投影后取min和max得到 2D 框的边界。
参数说明:camera_intrinsic是 3x3 矩阵,nuScenes 里每个相机通道不一样,别混用。ego2global和ego2global_cam的区别在于时间戳——标注框用的是关键帧的 ego pose,相机图像有自己的时间戳,如果时间不同步,需要用ego2global_cam做补偿。这个细节在源码里通常有注释,但容易被忽略。
2.3 遮挡判断与边界裁剪的处理策略
投影出来的 2D 框,有一部分会超出图像边界,还有一部分会被其他目标遮挡。源码里对这两种情况的处理方式不同:
边界裁剪比较简单,把x_min、y_min限制在[0, 0],x_max、y_max限制在[width, height]。但要注意,如果裁剪后框的面积小于某个阈值(比如 10 个像素),这个框就应该丢弃——留着也是噪声。
遮挡判断就麻烦一些。nuScenes 的标注里有num_lidar_pts和num_radar_pts字段,源码里一般用这两个值来过滤:点数太少的框,要么是远处的小目标,要么是被遮挡的,直接丢掉。我一般会把阈值设在 2 到 5 之间,具体看你的任务对召回率的要求。如果做可视化,阈值可以低一点;如果做训练数据,阈值高一点更干净。
还有一个坑:nuScenes 的相机图像有畸变,但camera_intrinsic是理想针孔模型的内参。源码里如果没有做去畸变处理,投影框在图像边缘会有偏移。常见做法是用cv2.undistort先把图像去畸变,或者用畸变系数修正投影点。这份源码里有没有做这一步,你拿到后要确认一下——如果没有,边缘目标的框会偏几个像素,做可视化看不出来,做训练就会引入噪声。
3. 跑通源码的完整步骤:环境、数据、参数三件事
3.1 环境依赖与 nuScenes 数据准备
源码依赖的库不多,核心就是numpy、opencv-python、nuscenes-devkit。我一般用 conda 建一个干净环境:
conda create -n nuscenes_3d2d python=3.8 conda activate nuscenes_3d2d pip install numpy opencv-python nuscenes-devkitnuscenes-devkit是官方提供的工具包,用来加载标注和元数据。版本上,我试过 1.1.x 和 1.2.x 都能跑,但 1.0.x 有几个 API 不兼容,建议别用太老的版本。
数据准备这块,nuScenes 的目录结构是固定的:
nuscenes/ ├── maps/ ├── samples/ │ ├── CAM_FRONT/ │ ├── CAM_BACK/ │ └── ... ├── sweeps/ └── v1.0-trainval/ ├── sample.json ├── sample_data.json ├── ego_pose.json ├── calibrated_sensor.json └── ...源码里一般会通过NuScenes类的dataroot参数指定这个路径。注意version参数要和你下载的数据版本一致——v1.0-trainval和v1.0-mini的标注数量差很多,但格式一样,调试阶段用 mini 版就够了,跑全量再换 trainval。
3.2 关键参数配置与单样本调试
源码里通常有一个config字典或者argparse的参数入口,几个关键参数必须确认:
| 参数名 | 含义 | 建议值 | 踩坑点 |
|---|---|---|---|
dataroot | 数据根目录 | 绝对路径 | 用相对路径容易找不到文件 |
version | 数据版本 | v1.0-mini / v1.0-trainval | 和实际下载的版本一致 |
cam_channels | 相机通道列表 | 6个通道全选或指定 | 少写一个通道就少一个视角 |
vis_threshold | 最小可见像素数 | 10 | 太小会保留噪声框 |
lidar_pts_threshold | 最小激光点数 | 2 | 太高会丢远处目标 |
单样本调试的步骤:先选一个sample_token,加载对应的 6 个相机图像和标注,跑投影,把结果画在图像上保存下来。这一步的目的是肉眼确认框的位置对不对。我一般会挑一个目标密集的场景,比如十字路口,这样能同时检查多个框的投影精度。
from nuscenes import NuScenes import cv2 import numpy as np nusc = NuScenes(version='v1.0-mini', dataroot='./nuscenes', verbose=True) sample = nusc.sample[0] # 取第一个样本调试 for cam in ['CAM_FRONT', 'CAM_BACK', 'CAM_FRONT_LEFT', 'CAM_FRONT_RIGHT', 'CAM_BACK_LEFT', 'CAM_BACK_RIGHT']: sd_token = sample['data'][cam] sd = nusc.get('sample_data', sd_token) img_path = nusc.get_sample_data_path(sd_token) img = cv2.imread(img_path) # 获取该相机下的所有标注框 ann_tokens = [a for a in sample['anns']] for ann_token in ann_tokens: ann = nusc.get('sample_annotation', ann_token) # 调用投影函数,拿到2D框坐标 box_2d = project_3d_to_2d(ann, sd_token, nusc) if box_2d is not None: x1, y1, x2, y2 = box_2d cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) cv2.imwrite(f'debug_{cam}.jpg', img)逻辑说明:这段代码遍历 6 个相机通道,对每个通道加载图像和对应的标注,调用投影函数后在图像上画框。nusc.get_sample_data_path返回图像路径,sample['data'][cam]拿到的是sample_data的 token,不是图像路径本身——这个转换容易搞混。参数说明:sample['anns']是该样本的所有标注 token,但并不是每个标注在每个相机里都可见,投影函数内部会做可见性判断。
3.3 批量处理与结果保存格式
单样本跑通后,批量处理就是套一层循环。源码里一般会提供一个process_all函数,遍历nusc.sample列表,对每个样本的每个相机通道做投影,结果保存成 JSON 或 CSV。
保存格式我建议用 JSON,结构清晰,方便后续解析:
{ "sample_token": "xxx", "cam_front": [ { "ann_token": "yyy", "category": "vehicle.car", "bbox_2d": [x1, y1, x2, y2], "visibility": "high" } ] }批量跑的时候注意内存——nuScenes 全量有 4 万多个样本,每个样本 6 个通道,如果一次性把所有图像加载到内存里,16G 内存的机器直接爆。源码里一般用生成器或者分批处理,我一般会每 1000 个样本存一次盘,防止中途崩了前功尽弃。
4. 避坑与排查:投影框偏移、丢失、错位的常见原因
4.1 框整体偏移:坐标系变换矩阵用反了
现象:投影出来的框整体往一个方向偏,所有目标都偏,不是个别框的问题。
原因:ego2global和global2ego搞混了,或者sensor2ego没有求逆就直接用。矩阵乘法的顺序也有影响——A @ B和B @ A结果完全不同。
解决:打印变换矩阵,确认平移量的数值范围。ego 到 global 的平移量是车辆在全局地图上的位置,通常是几百米到几公里;global 到 ego 的平移量应该是这个值的相反数。如果符号不对,就是求逆搞错了。
4.2 部分框消失:z 轴判断和可见性过滤太严
现象:有些目标明明在图像里能看到,但投影结果里没有。
原因:两个可能——一是box_cam[2] <= 0的判断把相机后方的目标丢了,但有些目标虽然中心在后方,边缘还在前方;二是num_lidar_pts阈值设太高,把远处的小目标过滤掉了。
解决:对于第一种情况,不要用中心点的 z 判断,用 8 个角点的 z 判断——只要有一个角点的 z 大于 0,就保留。对于第二种情况,把阈值降到 1 或者 0,先看召回率,再根据任务需求调整。
4.3 框的形状不对:角点投影后没有取 min/max
现象:框的位置大致对,但宽高比例明显不对,有的框特别扁,有的特别窄。
原因:只投影了中心点,然后用 3D 尺寸估算 2D 宽高。这种做法在目标正对相机时还行,一旦有旋转角度,误差就很大。
解决:老老实实投影 8 个角点,取min和max。源码里如果有get_8_corners函数,确认它有没有被调用。角点计算要注意旋转顺序——nuScenes 用的是四元数rotation,不是欧拉角,转换的时候别搞错。
4.4 时间戳不同步:ego pose 和相机图像对不上
现象:车辆运动快的场景里,框的位置有滞后或超前,静止场景看不出来。
原因:标注框用的是关键帧的 ego pose,相机图像有自己的时间戳,两者不完全同步。nuScenes 的关键帧间隔是 0.5 秒,车速 10m/s 的话,0.5 秒就是 5 米的位移,投影误差很明显。
解决:用sample_data里的ego_pose_token拿到相机时刻的 ego pose,而不是用sample里的。源码里如果有ego2global_cam参数,就是干这个用的。如果源码没处理,需要自己补上。
4.5 图像畸变未校正:边缘框偏移
现象:图像中心区域的框很准,越往边缘偏得越多。
原因:camera_intrinsic是理想针孔模型的内参,但实际图像有畸变。源码里如果没有去畸变步骤,边缘目标的投影点会有几个像素到十几个像素的偏移。
解决:用cv2.undistort对图像去畸变,畸变系数在calibrated_sensor的camera_distortion字段里。或者用cv2.projectPoints代替手动矩阵乘法,它会自动处理畸变。代价是速度慢一点,但精度提升明显。
5. 进阶技巧:用投影结果做跨相机对齐与伪标签生成
投影结果拿到后,最直接的用法是做跨相机对齐。同一个目标在多个相机里都有投影框,可以用这些框做一致性校验——如果某个相机里的框和相邻相机的框在重叠区域对不上,说明投影有问题。我一般会选相邻相机的重叠区域(比如 CAM_FRONT 和 CAM_FRONT_LEFT 的边界),检查同一个目标的框是否连续。
另一个用法是生成 2D 检测的伪标签。nuScenes 的 3D 标注精度很高,投影到 2D 后可以直接当检测标签用。但要注意两点:一是遮挡目标要过滤,二是边界框要裁剪。我一般会设一个规则:num_lidar_pts >= 2且裁剪后面积大于 100 像素的框才保留。这样生成的伪标签,在 2D 检测器上跑出来的 mAP 比用 3D 框直接回归要高不少。
还有一个技巧是用投影结果做数据增强的约束。比如你做随机裁剪增强,裁剪后的图像里,原本的 2D 框如果被裁掉了一半,标签就要相应调整。这个逻辑用投影结果来做很自然——因为投影框本身就是从 3D 框算出来的,裁剪后重新投影一遍就行,不用手动改标签。
验证投影精度的方法:选几个样本,把投影框和图像里的实际目标做 IoU 对比。如果 IoU 普遍在 0.7 以上,说明投影链路没问题;如果低于 0.5,回去检查变换矩阵和畸变校正。我一般会抽 50 个样本做统计,均值和中位数都看一遍——中位数比均值更能反映典型情况,因为个别极端样本会把均值拉偏。
从那以后我每次拿到新的投影代码,都会先跑一个单样本可视化,确认框的位置和形状都对,再跑批量。这个习惯帮我省了很多返工的时间——批量跑完才发现框偏了,那才叫血泪经验。希望帮到你。
本文还有配套的精品资源,点击获取