
简介这份资源面向自动驾驶感知与计算机视觉方向的开发者提供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 python3.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(versionv1.0-mini, dataroot./nuscenes, verboseTrue) 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(fdebug_{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 个样本做统计均值和中位数都看一遍——中位数比均值更能反映典型情况因为个别极端样本会把均值拉偏。从那以后我每次拿到新的投影代码都会先跑一个单样本可视化确认框的位置和形状都对再跑批量。这个习惯帮我省了很多返工的时间——批量跑完才发现框偏了那才叫血泪经验。希望帮到你。本文还有配套的精品资源点击获取