ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

单目3D目标检测与BEV可视化:Python源码实战指南

单目3D目标检测与BEV可视化:Python源码实战指南 简介一套基于Python实现的单目2D和3D目标检测及BEV可视化源码包面向高校毕业设计、课程设计与项目开发场景适合具备一定Python基础的学生、教师及科研人员学习借鉴。资源共包含70个文件以46个Python源码文件为核心覆盖检测模型、多目标跟踪器、数据解析与可视化等模块另配有12个YAML配置文件、PDF/TXT说明文档及示例图片包体大小22.58MB结构清晰便于按目录检索。项目内集成KITTI数据集解析文档、多目标跟踪相关实现如moma_m3t、strong_sort等以及多种检测网络代码可直接用于单目场景下的目标检测、3D定位与BEV视图生成。目前已有164人学习下载适合在已有代码基础上二次开发也可作为课设、毕设的完整参考方案。1. 单目2D/3D目标检测与BEV可视化这套Python源码包解决什么问题单目3D目标检测只用一颗普通RGB相机同时输出2D框、带深度与朝向的3D框再投影成BEV鸟瞰图——这是自动驾驶感知、毕业设计和课程设计里出现频率很高的完整视觉任务。这套Python源码包把整条链路串在了一起YOLO系模型做2D检测单目深度预测恢复目标三维位置最后用坐标变换在鸟瞰图上把每个目标的位置和朝向画出来。它的价值在于把最容易绕晕的相机坐标系、KITTI标注规则和BEV投影做成了一整套可改可跑的代码。适合需要交课程作业、毕设的在校学生也适合想快速验证单目3D方案可行性的工程开发拿到手先看的是坐标系怎么转而不是模型结构本身。2. 环境与数据准备PyTorch、相机内参和KITTI标注该如何对齐2.1 依赖锁版本隔离环境这类源码包最常见的翻车点不是算法而是环境。代码里用了哪个版本的torchvision、opencv直接影响到后面的NMS调用和图像读写行为。直接pip install最新版往往会踩到接口变更的坑比如torchvision.ops.nms在不同版本的行为没有太大差别但数据集加载代码里如果用了老版本的datasets.ImageFolder参数就会报错。我一般会先建独立conda环境再按锁版本安装。# requirements.txt 关键依赖 torch1.12.1cu113 torchvision0.13.1cu113 opencv-python4.6.0.66 numpy1.21.6 pillow9.2.0 pyyaml6.0 tqdm4.64.0这段配置里torch和torchvision必须配对cu113对应CUDA 11.3。如果你手里的显卡驱动比较新直接换成torch2.1.0cu121和torchvision0.16.0cu121也行其他依赖不变。opencv-python锁4.6是因为4.7以后部分绘图接口的中文字体支持有变化对调试画框没什么影响但没必要冒险。numpy锁1.21是因为某些老代码里np.float在1.24以后被移除了如果源码包里还有np.float的写法这个版本能救你一次。安装完之后先跑一个最小验证把模型加载出来对单张图做一次前向输出shape是否符合预期。这一步能筛掉八成环境问题不用等跑到数据集加载才发现版本不对。常见做法是写一个test_forward.py里面只加载模型、跑一张随机图、打印输出维度三分钟就能出结果。2.2 KITTI标注格式字段顺序一个都不能错单目3D检测绝大多数复现都基于KITTI数据集。KITTI的label文件每一行对应一个目标字段顺序是固定的源码包里的数据加载器也会按固定下标去切分。如果你自己标数据或者从别的数据集转换过来最容易出错的地方就是把3D框的位置和尺寸顺序搞反导致画出来的框和2D框对不上。def parse_kitti_label(label_path): 解析KITTI label文件返回目标字典列表 objects [] with open(label_path) as f: for line in f: parts line.strip().split() obj { type: parts[0], # Car / Pedestrian / Cyclist truncation: float(parts[1]), occlusion: int(parts[2]), alpha: float(parts[3]), # 观测角度训练3D朝向用 box2d: [float(x) for x in parts[4:8]], h: float(parts[8]), w: float(parts[9]), l: float(parts[10]), x: float(parts[11]), y: float(parts[12]), z: float(parts[13]), yaw: float(parts[14]), } objects.append(obj) return objects这段代码里box2d是图像上的左上角x、y和右下角x、y。h、w、l分别是目标在相机系下的真实高度、宽度和长度单位是米注意这个顺序是h、w、l不要习惯性写成l、w、h。x、y、z是目标底面中心点在相机坐标系下的坐标x向右、y向下、z向前这是KITTI相机系的约定。yaw是绕相机系z轴前向轴的偏航角单位为弧度。字段顺序一旦错后面所有3D框投影都会歪。比如把h和l写反目标会变成长方体倒着放在BEV图里尤其明显。源码包里一般会自带的visualize脚本把2D框和3D框同时画到图上第一次跑的时候一定要用标注工具或者KITTI官方可视化器交叉验证一次。标错了不报错只会画得怪这是数据准备阶段最难察觉的坑。2.3 相机内参读取与图像缩放相机参数是单目3D检测的命门。KITTI的calib文件里最关键的是P2矩阵它是一个3x4的投影矩阵包含了相机内参fx、fy、cx、cy和外参的合成。很多复现代码只取P2里的内参用另一部分会配合Tr_velo_to_cam做坐标转换两套逻辑要对齐。import numpy as np def load_calib(calib_path): 读取KITTI calib文件返回内参K、投影矩阵P2、雷达到相机外参 with open(calib_path) as f: lines f.readlines() P2 np.array([float(x) for x in lines[2].strip().split( )[1:]]).reshape(3, 4) Tr_velo_to_cam np.array( [float(x) for x in lines[5].strip().split( )[1:]]).reshape(3, 4) K P2[:3, :3] return K, P2, Tr_velo_to_cam读取后K就是3x3内参矩阵包含fx、fy和主点cx、cy。P2的完整形式是K [R | t]但KITTI里相机基本是固定的很多代码直接用P2投影3D点就能画框。需要特别注意的是图像缩放如果你的推理管线把输入图缩放到416x416那内参里的fx、fy、cx、cy必须同步按比例缩放。例如原图1242x375缩放后宽度比例是416/1242那fx也要乘以这个比例否则投影出来的3D框位置会整体偏移尤其在图像边缘偏移量能达到几十个像素。我自己的习惯是写一个adjust_intrinsic(image_shape, K, target_shape)工具函数任何前处理里动了图像尺寸都强制调用它返回缩放后的内参。这比在多个文件里手动改数值靠谱得多。单目3D检测对深度误差很敏感而深度恢复又依赖fx如果内参里的fx是错的3D框中心点的距离会系统性偏差这种错在单张图上不容易看出来但在BEV俯瞰图里目标会整体漂移出车道线。3. 2D与3D推理链路从YOLO框输出到单目三维框回归3.1 2D检测头输出与NMS处理整个推理链路的第一段是2D目标检测。源码包里通常用YOLO系列或者更轻量的检测头输出张量里每个目标包含坐标框、置信度和类别。2D框的作用不只是给人看的框它还为3D回归提供两个关键信息目标在图像上的中心点位置以及目标的尺度先验。单目3D检测经常先看2D框的高度来估计目标距离因为同样的车在远处更矮。from torchvision.ops import nms def postprocess_2d(raw_pred, conf_thres0.4): 过滤低置信度目标并做NMS raw_pred: [N, 6]每行为 x1, y1, x2, y2, conf, cls keep raw_pred[:, 4] conf_thres pred raw_pred[keep] if not len(pred): return pred idx nms(pred[:, :4], pred[:, 4], iou_threshold0.5) return pred[idx]这里直接用torchvision的NMS比自己写循环快很多也不会在batch处理时拖后腿。conf_thres是置信度阈值0.4在KITTI这种质量较高的数据集上算比较宽松如果你在实际道路视频里跑可能要升到0.5或者0.6因为遮挡和模糊目标多了低置信度的假阳性会爆炸。iou_threshold是NMS的重叠阈值0.5是常规值调低到0.4会让密集场景下挨着的两个行人只保留一个调高到0.6又可能让同一个目标出两个框。这个参数对最后3D框数量影响非常大3D检测依赖2D中心点两个2D框重叠时中心点只差几个像素恢复出的3D位置能差出半米。3.2 单目3D深度与中心点恢复2D框拿到后核心任务是恢复目标的3D中心点。单目相机本身无法直接测距所以常见做法是让网络回归目标的深度值而这个深度通常不是直接输出绝对值而是输出一个对数空间的值或者相对先验尺度的偏移这样能缓解远近距离差异过大的问题。def recover_3d_center(uv, depth, K): 从2D中心像素坐标和深度恢复相机系3D坐标 uv: [u, v] 像素坐标 depth: 相机系下深度单位米 K: 内参矩阵 [fx, 0, cx; 0, fy, cy; 0, 0, 1] cx K[0, 2] cy K[1, 2] fx K[0, 0] fy K[1, 1] x (uv[0] - cx) * depth / fx y (uv[1] - cy) * depth / fy return np.array([x, y, depth])这段代码的原理是相机针孔模型的反投影给定像素坐标和深度能唯一确定相机系下的三维坐标。x和y的计算就是把像素偏移量除以内参焦距再乘上深度。这里uv是2D检测框的中心点一般取((x1x2)/2, (y1y2)/2)。depth如果直接来自网络输出单位必须保持一致KITTI里是米。如果网络输出的是log(depth)一定要torch.exp还原如果网络输出的是相对某个锚点深度的残差要加上锚点深度再参与计算。这一步出错不会报异常只会表现为3D框距离忽远忽近。深度值是单目3D检测里玄学意味最重的地方。同一个模型在KITTI上能跑到不错的ATE距离误差换到自采数据就崩原因往往就是深度分布不一致。源码包给的预训练权重大多在KITTI上训过直接用能看效果如果换自己的数据至少要做一次深度分布的统计分析不然BEV图里的目标位置和真实情况完全对不上。3.3 3D框角点生成与图像投影知道3D中心点和目标尺寸h、w、l以及朝向yaw之后就能生成目标3D框的8个角点再投影到图像上叠加显示。这是验证整个3D估计最直观的环节也是毕设展示里最出效果的部分。def compute_corners(h, w, l, yaw): 生成3D框8角点目标中心为原点返回(8,3) R np.array([ [np.cos(yaw), -np.sin(yaw), 0], [np.sin(yaw), np.cos(yaw), 0], [0, 0, 1] ]) x np.array([l/2, l/2, -l/2, -l/2, l/2, l/2, -l/2, -l/2]) y np.array([w/2, -w/2, -w/2, w/2, w/2, -w/2, -w/2, w/2]) z np.array([0, 0, 0, 0, -h, -h, -h, -h]) corners np.stack([x, y, z], axis1) corners (R corners.T).T return corners def project_3d_box_to_image(corners, center, P2): 把目标中心的3D框角点平移到相机系并投影到图像 corners_cam corners center corners_h np.hstack([corners_cam, np.ones((8, 1))]) uv (P2 corners_h.T).T uv uv[:, :2] / uv[:, 2:3] return uv.astype(int)compute_corners里底面四个点的z为0顶面四个点的z为-h这符合KITTI里目标高度沿y轴向下、z轴向前、x轴向右的坐标系。生成的角点是以目标中心为原点再通过center平移进相机系。P2投影时最后一步除以齐次坐标的第三维完成透视除法。参数上需要注意yaw角度的正负。KITTI里的yaw定义是目标朝向与相机z轴的夹角绕y轴负方向旋转不同复现代码实现里可能多加一个负号。如果画出来的3D框像是倒置或者左右镜像第一反应就是把yaw取反或者把旋转矩阵写成绕y轴而不是绕z轴。我一般会在角点生成后打印corners[:, :2]的x范围检查最左侧和最右侧是否与2D框对齐快速定位符号问题。4. BEV鸟瞰图可视化坐标变换矩阵与投影实现4.1 坐标系约定相机系、车体系与BEV图BEV可视化的第一步是搞清楚坐标系。相机系下目标位置是x向右、y向下、z向前这个坐标系适合图像投影但不适合鸟瞰视图。BEV图中我们希望看到的是俯视效果目标的前向在图中朝上左向在图中朝左。因此需要把一个三维坐标映射到二维俯视平面丢掉垂直方向的信息或者把垂直高度用颜色区分。KITTI里提供了雷达到相机的标定参数Tr_velo_to_cam它把激光雷达坐标系下的点转到相机系。激光雷达坐标系的x轴指向前方y轴指向左侧z轴指向上方。这恰好适合直接画BEV把相机系下的目标点转回雷达系取x、y两个分量就能表达水平位置。源码包里常见的做法是把这一步写成一个刚体变换配合np.linalg.inv求逆。def camera_to_vehicle(pts_cam, Tr_velo_to_cam): 相机系3D点 - 车体系(Velodyne)3D点保留x,y画BEV T np.eye(4) T[:3, :4] Tr_velo_to_cam T_inv np.linalg.inv(T) n pts_cam.shape[0] pts_h np.hstack([pts_cam, np.ones((n, 1))]).T pts_veh (T_inv pts_h).T[:, :3] return pts_veh这里注意Tr_velo_to_cam本身是3x4缺少最后一行的[0,0,0,1]拼成4x4再求逆才能正确完成刚体变换。有人图省事直接对3x4矩阵做inv结果数值完全不对目标点在BEV上会飞到宇宙尽头。如果源码包里没有雷达到相机外参也可以用自定义的外参代替只要保证坐标轴方向约定一致即可。4.2 用单应性矩阵把原图转成鸟瞰图很多源码包还会额外做一个“整张图像转鸟瞰图”的功能把一张前视图变成俯视视角的底图再把检测到的目标叠加上去。这个基于单应性矩阵Histogram的算法不需要三维重建只需要原图上四个参考点到鸟瞰图上四个对应点的映射即可。import cv2 import numpy as np def warp_to_bev(img, src_pts, dst_pts, scale6): src_pts: 原图上四点dst_pts: 鸟瞰图对应四点 scale: 输出图相对原图的放大倍数保证远处目标可见 H cv2.getPerspectiveTransform(src_pts.astype(np.float32), dst_pts.astype(np.float32)) out_w img.shape[1] * scale out_h img.shape[0] * scale bev cv2.warpPerspective(img, H, (out_w, out_h)) return bev, H单应性矩阵H对点顺序极其敏感。src_pts和dst_pts的对应关系必须一一对应比如原图的左上对应鸟瞰图的左上。如果顺序乱了结果图会像被揉过的纸一样扭曲而且warpPerspective不会给任何报错提示纯视觉上很难排查。我一般固定用左上、右上、左下、右下这个顺序并先在空白图上画四个点确认对应关系。scale参数决定输出图尺寸。KITTI原图1242x375scale取6时输出图是7452x2250内存占用不算小但能保证纵向200米内的目标都清晰可见。如果只关心近处目标scale取3就够。这里要权衡scale太小远处目标全部挤在几个像素里scale太大BEV图刷新频率受影响视频实时处理会卡。4.3 3D框角点在BEV上的绘制有了车体系坐标和BEV底图绘制就只剩投影和连线。关键画法是取3D框的底面四个角点做透视投影画一个闭合四边形。底面四个角点代表目标在地面上的实际占用范围这是BEV可视化最常展示的形状。def draw_bev_boxes(bev_img, corners_veh_list, class_ids, scale6): 在BEV图像上绘制目标的底面框 corners_veh_list: 每个目标的8角点车体系坐标 h, w bev_img.shape[:2] colors [(0, 200, 0), (0, 0, 255), (255, 0, 0)] for corners, cls in zip(corners_veh_list, class_ids): # 底面四个角点取索引1,2,3,4对应的点 pts corners[[1, 2, 3, 0]][:, :2] # 车体系x向前 - 图像y向上车体系y向左 - 图像x向右 px (pts[:, 1] * scale w // 2).astype(int) py (h - 50 - pts[:, 0] * scale).astype(int) poly np.vstack([px, py]).T.astype(np.int32) cv2.polylines(bev_img, [poly], True, colors[cls % 3], 3) return bev_img这段代码里px由车体系y方向乘scale再加图像中心偏移得到py由前向x乘scale后取负保证目标越远越往图像上方走。常量h - 50是给车辆自身位置留一个边距代表自车所在的位置这张图上底部的空区就是自车后方。实际项目中这个偏移量要根据相机高度和scale重新标定最省事的方法是把自车位置固定画一个三角箭头然后用这个箭头校准前景目标的位置。BEV绘制常见问题在源码包里基本都遇到过一是角点索引选错画出来的四边形叉成蝴蝶状二是scale符号弄反目标越远画得越往下。遇到这类问题拿一个标注正确的目标把它的3D框角点打出来和2D框投影对照十有八九能定位到坐标变换的一步。5. 避坑与常见问题排查五个坐标与尺度翻车现场5.1 3D框整体漂移边缘目标偏差越来越大现象图像中间的3D框贴着目标图像边缘的3D框整体向外偏距离越大的目标偏得越离谱。 原因内参没有随图像缩放同步更新。前处理把原图缩放到416x416但K值里的fx、fy、cx、cy还是原图尺寸的深度恢复时直接用缩放后的像素坐标计算误差在图像四周被放大。 解决统一走adjust_intrinsic流程任何图像尺寸变换后都把内参按宽高比例更新。调试时打印原图尺寸、网络输入尺寸、当前K值三步数据对齐后再看结果。5.2 BEV底图拉伸形变道路线变成弧线现象用warpPerspective生成的鸟瞰底图看起来像鱼眼镜头原本笔直的车道线变成曲线。 原因单应性矩阵的对应点选得不对或者src_pts与dst_pts的顺序没有一一对应。常见做法是直接在路面选一个矩形区域作为ROI但矩形在图像上的实际对应关系需要考虑透视关系不能简单认为图像靠上的点一定对应BEV更远的点。 解决先把src_pts和dst_pts单独画图打印用点序号标记确认对应关系再选一个更大的ROI重算H。如果变形依然明显改用标定好的地面平面方程做逆投影不要硬凑单应矩阵。5.3 深度输出忽近忽远同一个目标前后帧差好几米现象单张图看起来还好视频连续帧里同一个目标的位置在BEV上跳来跳去一会儿近一会儿远。 原因网络输出的深度被当作直接深度用了但很多单目3D模型输出的是对数深度或相对深度的残差。常见复现代码在推理阶段会做exp如果源码包里另外封装了一层处理很容易重复或漏掉。 解决在推理代码的深度输出处打一个打印点输出原始张量的数值范围。如果数值集中在0到0.1之间大概率对数空间如果加了exp后范围变成几十米那才适合直接参与反投影。用一段视频把所有目标深度范围统计出来和真实距离分布比对比肉眼判断快很多。5.4 目标框歪斜、倒置像被翻过一样现象3D框投影在图像上时朝向与目标实际姿态相反车身左右颠倒或者框顶和框底反了。 原因yaw角符号和坐标系绕向不一致。KITTI的坐标系是x向右、y向下、z向前目标绕z轴旋转但torch或numpy的旋转矩阵默认是数学坐标系的逆时针旋转两者会差一个负号。另外KITTI里目标朝向的参考方向也有绕x轴和绕z轴两种定义复现代码混用很常见。 解决在compute_corners里临时把yaw取负或者把旋转矩阵改为绕y轴旋转哪个结果和2D框贴合度高就固定用哪个。每次换数据集都要重新确认这个符号因为标注工具生成yaw的约定可能不一样。5.5 显存不足或推理速度慢到没法看现象批量推理时显存直接爆掉单张图推理又慢到2~3秒一帧BEV视频流完全跑不动。 原因输入分辨率过大、batch size设得过高或者模型在推理时仍然开着dropout等训练专用层。有些源码包为了视觉效果好把网络输入放大到1242x375GPU占用自然高。 解决先把batch size降到1输入缩到416x416并在模型推理前加model.eval()和torch.no_grad()。如果这样还慢检查是不是每个目标单独跑了一次前向很多复现代码在这块写得很宽。BEV可视化本身不占多少算力瓶颈几乎都在检测模型。6. 验证与进阶调整从BEV单帧叠加到视频流跟踪6.1 用KITTI指标量化验证别只看画得好不好看画框好看不代表3D位置准确用指标说话才有说服力。最少要统计两个数3D中心点距离误差ATE以及3D框的BEV中心误差。取目标底面中心在车体系的x、y分别和真值做差求平均结果单位是米。def center_distance_error(pred_pos, gt_pos): pred_pos / gt_pos: [N, 3] 相机系中心坐标 diff pred_pos - gt_pos dist np.sqrt(np.sum(diff ** 2, axis1)) return np.mean(dist), np.median(dist)这里建议同时看mean和median因为少数几个误检目标的距离误差会把mean拉高很多。毕设答辩时一个mean误差和一张BEV叠加图比十张效果图更有说服力。单目3D检测能做到3米以内的平均中心误差已经算不错超过5米就要检查深度恢复的预处理是不是有问题。6.2 真值叠加比对交叉验证坐标变换BEV可视化跑通后立刻做一件事把KITTI真值标签里每个目标的底面框也画到同一张BEV图上用半透明颜色区分检测框和真值框。这一步能一次性暴露所有坐标变换问题比如外参方向反了、深度尺度不对、朝向符号错乱这些在单帧图上都很难靠肉眼判断。切换两个坐标系前把真值的位置打印出来比对如果检测框整体绕自车旋转了一个固定角度那多半是Tr_velo_to_cam求逆时少了旋转矩阵转置如果检测框和真值框形状一致但距离差恒定那深度回归里多了个偏移量。从那次教训之后我每次换数据源都强制先打一帧真值到BEV上看重合度。6.3 从单帧到视频流BEV坐标下的最近邻跟踪单帧检测做完进阶常做的是在BEV平面上做多目标跟踪。BEV坐标天然是物理尺度可以在相邻帧之间用最近邻匹配关联目标比在图像像素坐标系里做更稳定。def match_tracks(prev_pos, cur_pos, max_dist2.0): prev_pos: 上一帧每个目标的车体系坐标 [M,2] cur_pos: 当前帧的检测坐标 [N,2]返回匹配对索引 from scipy.spatial.distance import cdist d cdist(cur_pos, prev_pos) pairs [] for i in range(len(cur_pos)): j np.argmin(d[i]) if d[i, j] max_dist: pairs.append((i, j)) return pairsmax_dist设2米的原因是单目检测的深度误差本身就有一定随机性在BEV上相邻帧同一目标偏移超过2米大概率是换了一个目标或者误检测。匹配到轨迹后可以给每个目标附一个唯一ID并在BEV图上画出它在最近几帧的运动轨迹线看起来就接近一个完整的自动驾驶感知模块了。这套源码包把2D检测、3D回归和BEV可视化打通之后剩下的事情就是调深度头和坐标变换。我自己最深的体会是单目3D检测的算法结构并不复杂真正让人反复翻车的是内参、外参、朝向角这些“看似清楚实则容易混”的量的正负号和单位从那以后我每次跑一个新数据集都强制走一遍真值叠加到BEV的验证流程再往下调参数。希望帮到你。本文还有配套的精品资源点击获取
返回列表