简介:面向自动驾驶、机器人导航等场景的3D目标检测实战项目,将YOLO实时检测与深度估计相结合,实现目标在三维空间中的定位与测距。资源共7个文件,以Python源码为主,配合requirements.txt环境依赖与README说明文档,压缩包大小约20KB,属于轻量级可运行的算法示例。源码包含深度估计网络、3D边界框处理、相机参数加载、检测模型及主运行脚本,基本覆盖从数据准备、模型推理到结果输出的完整流程。已有119人学习使用,适合具备一定深度学习基础、希望快速上手YOLO+深度估计3D检测思路的开发者参考,也可作为进一步扩展训练与调优的起点。
1. YOLO+深度估计的3D目标检测:为什么2D检测满足不了现在的项目
做安防监控、自动驾驶感知或者工业抓取定位时,2D检测框只能告诉你“那里有一个人”,但给不出“他在你前方几米、偏左多少度”。当系统需要让机械臂去抓、让车辆去避让、或者把目标投到地图上做轨迹分析时,缺的就是第三维信息。用YOLO+深度估计实现的3D目标检测,核心思路是在YOLO输出2D包围框的基础上,叠加一个深度值,再通过相机内参把像素坐标投影回相机坐标系,最终得到带长宽高和朝向的3D框。这个方案的好处是:不需要激光雷达、不需要双目相机,单目摄像头加一套推理代码就能跑,硬件成本低,适合已有摄像头存量场景的升级改造。适合三类人:想把2D检测系统升级成3D感知的工程师、刚入门的算法应用开发、以及做技术选型想快速验证效果的团队。
2. 方案原理与选型:为什么是YOLO+深度估计而不是纯3D检测网络
2.1 三种3D目标检测路线的取舍
常见做法是三条路线。第一是纯单目3D检测网络,比如MonoDETR、SMOKE这类端到端模型,直接回归出3D框的中心点、尺寸和朝向。这类模型性能上限高,但训练数据成本不小,KITTI数据集标注一套下来要花不少力气,而且在自定义场景上泛化能力不好调。第二是RGB-D方案,用深度相机或激光雷达采集深度图,配合2D检测网络,精度高,但对硬件有依赖,D435i这类深度相机的有效距离也就在5-10米左右,室外大场景不太够。第三就是标题里这种:YOLO提供2D检测框和类别,深度估计网络提供深度值,然后利用相机模型做坐标变换。这套组合的优势在于两个模型可以分别替换,检测效果不好就换YOLO版本或调权重,深度不准就换更好的深度估计网络,不用整个推倒重来。
选型的核心逻辑是解耦。YOLO只负责“目标在哪、是什么”,深度估计只负责“有多远”,坐标系换算只负责“怎么把两者合并”。这种松耦合结构对工程落地很友好,因为模型训练、数据采集、误差定位可以分开做。如果你的项目已经有稳定的2D检测模型,那只需要补一个深度估计网络和坐标系转换代码,改动量最小。这也是我把这个方案定位成“优质项目实战”的原因——它不是一个研究课题,而是一个能快速组装起来跑通的工程框架。
深度估计网络本身也有两种选择,一种是单目相对深度估计,比如MiDaS、ZoeDepth、Depth Anything,输出结果是0到1之间的相对深度图,没有物理尺度;另一种是带尺度回归的模型,比如直接训练回归真实距离的深度网络。这里有个关键点要搞清楚:绝大多数开源模型输出的是相对深度,不是真实距离。这意味着你拿到深度图之后不能直接用,必须先做尺度恢复——要么用已知目标尺寸反推,要么用一段真实距离做线性拟合,要么干脆用深度相机的真值重新训练。这个坑我后面会专门讲,这是整个方案里最容易翻车的地方。
2.2 像素坐标到3D坐标的变换:相机内参是关键
有了2D框和深度,剩下的工作就是几何投影。假设相机满足针孔模型,一个3D点在那[X, Y, Z](相机坐标系)投影到像平面得到[u, v](像素坐标),关系是:
u = fx * X / Z + cx v = fy * Y / Z + cy反过来,如果已知像素坐标[u, v]和该点深度Z,就可以还原3D坐标:
X = (u - cx) * Z / fx Y = (v - cy) * Z / fy Z = Z这里的fx、fy是归一化焦距(单位是像素),cx、cy是光心在像素坐标系的位置。这四个参数合起来就是相机内参矩阵K。在拿到内参之前,算出来的3D坐标都是扭曲的。实际项目里,YOLO检测到目标框之后,通常取框底边中点作为目标的“着地点”,因为单目3D检测里目标与地面的接触点在几何上是稳定的。如果你取框中心点,而目标是一个站着的人或者一辆车,那你算出来的Z其实是目标身体中间某处的距离,而不是它站的位置的距离,后续做地面投影就偏了。
下面是这个坐标变换的代码实现,完整考虑了numpy批量处理和YOLO输出格式:
import numpy as np def project_2d_to_3d(boxes, depth_map, K): """ 将YOLO检测框转换为相机坐标系下的3D坐标 Args: boxes: [x1, y1, x2, y2] 像素坐标,可以直接用YOLO的xyxy输出 depth_map: 与图像同尺寸的深度图,单位是米(需要提前做过尺度恢复) K: 相机内参矩阵 [fx, fy, cx, cy] Returns: 每个目标框底边中点的3D坐标 [X, Y, Z] """ fx, fy, cx, cy = K results = [] for box in boxes: x1, y1, x2, y2 = [int(v) for v in box] # 取底边中点作为着地点,目标站立类物体的关键点 u = (x1 + x2) / 2.0 v = y2 # 底边 # 深度图里取底边中点附近一个小邻域的中位数,抵抗深度估计的离群点 patch = depth_map[max(0, v-5):v+6, max(0, int(u)-5):int(u)+6] Z = np.median(patch) # 逆投影公式 X = (u - cx) * Z / fx Y = (v - cy) * Z / fy results.append((X, Y, Z, (x2-x1), (y2-y1))) return results这段代码里有两个参数值得注意。一是深度采样方式,我用了底边中点附近11x11窗口的中位数而不是单点像素,原因是深度估计网络在物体边缘经常出现异常值,单点数据非常不稳定,中位数能滤掉多数跳变。二是v取的是底边,这对行人、车辆这类与地面接触的目标是合理的,但如果是无人机视角下的俯视检测,底边就不适用了,应该取框中心对应地面投影。这就是我说“边界要看场景”的原因。
3. 用YOLOv8+MiDaS跑通最小实现:环境搭建与核心代码
3.1 依赖安装与模型准备
环境选择上,我建议直接用ultralytics的YOLOv8,虽然现在YOLO已经出到v11甚至更新的版本,但v8的文档最全、API最稳,社区踩坑记录也最多,适合做参考实现。深度估计网络用MiDaS,因为它支持ONNX导出,部署灵活,而且有多个预训练权重可以选。先安装依赖:
pip install ultralytics opencv-python torch torchvision onnxruntime模型文件准备两样:YOLOv8的预训练权重,路径一般是yolov8n.pt或yolov8s.pt,n版本最快,s版本精度更好;MiDaS的ONNX模型文件可用网上的现成脚本转换得到。推理时YOLO用PyTorch加载,MiDaS用ONNX Runtime加载,两者互不干扰。加载代码如下:
from ultralytics import YOLO import onnxruntime as ort # YOLOv8检测模型,nano版本在CPU上也能跑到十几帧 detector = YOLO("yolov8n.pt") # MiDaS ONNX模型,输入尺寸384x384,输出同尺寸相对深度 midas_session = ort.InferenceSession("midas_v21_small.onnx")这里有个性能优化点:MiDaS输入输出都是384x384,而YOLO输入是640x640,如果两张图分别做resize,深度图和原图的像素对应关系就乱了。常规做法是先把原图resize到384x384推理出低分辨率深度图,再resize回原图尺寸,和YOLO检测框对齐。这样做会损失边缘细节,但对目标级深度估计够用。如果你用的是D435i这类RGB-D相机,就没有这个对齐问题,因为深度图本身就是对齐到彩色图输出的。
3.2 完整推理流程:检测-深度-投影一条龙
整个算法的pipeline是:读取一帧图像→YOLO检测出所有目标框→MiDaS生成深度图→把深度图恢复到原图尺寸→对每个目标框执行逆投影→得到3D坐标。下面给一个可以整体复制运行的最小骨架:
import cv2 import numpy as np import torch from ultralytics import YOLO import onnxruntime as ort class YOLO_Depth_3D: def __init__(self, yolo_weights="yolov8n.pt", midas_onnx="midas_v21_small.onnx", K=None): self.detector = YOLO(yolo_weights) self.midas = ort.InferenceSession(midas_onnx) # 相机内参,fx,fy,cx,cy,用你自己的标定值替换 self.K = K if K else (500.0, 500.0, 320.0, 240.0) self.midas_input_size = 384 def estimate_depth(self, bgr_img): """MiDaS推理,返回与输入图像同尺寸的单通道深度图""" h, w = bgr_img.shape[:2] img = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2RGB) img_resized = cv2.resize(img, (self.midas_input_size, self.midas_input_size)) img_norm = (img_resized / 255.0 - 0.5) / 0.5 # 归一化,ONNX模型的常规要求 img_tensor = img_norm.transpose(2, 0, 1)[None].astype(np.float32) pred = self.midas.run(None, {"input.1": img_tensor})[0] depth = cv2.resize(pred[0, 0], (w, h)) # 缩放到原图尺寸 return depth def detect_and_project(self, bgr_img): """完整流程:YOLO检测 + 深度估计 + 3D投影""" results = self.detector(bgr_img, conf=0.4, iou=0.5, verbose=False)[0] boxes = results.boxes.xyxy.cpu().numpy() classes = results.boxes.cls.cpu().numpy() depth = self.estimate_depth(bgr_img) # 这里只是示例,实际需要做深度尺度恢复,见第4章 # depth = depth_scale_recovery(depth, reference_points) objects_3d = [] for box, cls in zip(boxes, classes): u = (box[0] + box[2]) / 2.0 v = box[3] patch = depth[int(v)-5:int(v)+6, int(u)-5:int(u)+6] Z = np.median(patch) X = (u - self.K[2]) * Z / self.K[0] Y = (v - self.K[3]) * Z / self.K[1] objects_3d.append({ "class": self.detector.names[int(cls)], "box": box.tolist(), "position": (X, Y, Z) }) return objects_3d这段代码里,conf和iou这两个参数值得细说。conf=0.4在通用场景是平衡点,如果你做的是工业抓取、场景固定的任务,可以调到0.6以上减少误检;如果做复杂背景的行人检测,0.3甚至0.25更合适。iou在窄小目标密集的场景要调低到0.3,因为两个挨得近的框可能被NMS合并掉一个。另外,ONNX Runtime会话初始化可以增加providers=['CUDAExecutionProvider', 'CPUExecutionProvider'],GPU推理时能自动走CUDA,没有显卡就自动退回CPU。
4. 相机标定与深度尺度恢复:精度能不能用的关键
4.1 棋盘格标定相机内参:15分钟拿到内参矩阵
3D坐标变换的精度完全建立在相机内参准确的基础上。很多项目直接拿网上公开的参数或者用厂家给的出厂值,这在普通监控里还行,但你要是做机械臂抓取或者车辆测距,差10个像素会让目标位置偏出去几十厘米。最可靠的办法还是拿一张棋盘格图板,在现场标定一次。OpenCV的标定接口把大部分逻辑封装好了,你要做的是采集照片和填参数:
import cv2 import numpy as np import glob checkerboard_size = (9, 6) # 内角点数,不是棋盘格格子数 square_size = 0.025 # 每格边长,单位:米 # 准备棋盘格的物理3D坐标,所有角点都在Z=0平面 objp = np.zeros((checkerboard_size[0] * checkerboard_size[1], 3), np.float32) objp[:, :2] = np.mgrid[0:checkerboard_size[0], 0:checkerboard_size[1]].T.reshape(-1, 2) objp *= square_size obj_points = [] img_points = [] # 从不同角度拍的10-15张标定板照片 for fname in sorted(glob.glob("calib_images/*.jpg")): img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, checkerboard_size, None) if ret: obj_points.append(objp) corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)) img_points.append(corners2) ret, K, dist, rvecs, tvecs = cv2.calibrateCamera(obj_points, img_points, gray.shape[::-1], None, None) print("内参矩阵:\n", K) print("畸变系数:\n", dist)采集照片时有几个硬性要求:标定板要占画面三分之一以上,板子要倾斜不同角度,尽量覆盖画面四个角落,不要在同一个角度拍多张。光线太暗或反光太强都会导致角点检测失败。标定结果里,fx和fy如果相差超过1%-2%,说明镜头存在明显畸变或标定板照片拍变形了,需要重新采集。像素主点cx,cy理论上应该在图像中心附近,如果偏差超过图像宽高的一半,那标定一定是哪里出了问题。
下表整理了标定结果里几个关键参数的含义和异常阈值,后面做3D恢复判断数据可不可信时可以直接对着看:
| 参数 | 含义 | 正常范围参考 | 异常排查方向 |
|---|---|---|---|
| fx | 水平方向归一化焦距(像素) | 取决于分辨率和视场角,1/2画幅常见1000-2000 | 标定板尺寸填错 |
| fy | 垂直方向归一化焦距(像素) | 与fx接近,差1%-2%内 | 照片有梯形畸变 |
| cx, cy | 光心像素坐标 | 接近图像中心 | 镜头模组装配偏移 |
| k1, k2 | 径向畸变 | 通常在-0.5到0.5之间 | 用了鱼眼镜头需单独标定模型 |
4.2 深度尺度恢复的三种做法
MiDaS这类模型输出的是相对深度,数值越大代表越近,但和真实距离之间没有一个固定的比例系数。这就带来一个经典问题:同一个人站在5米和10米处,模型输出的深度值差别可能不成比例。要把它变成能用的物理距离,有三种实践路线。
第一种是单点线性拟合。找一个固定点,实测它的真实距离,同时读模型的输出深度,算出比例系数k = 真实距离 / 模型深度。然后所有预测距离都乘上这个系数。这种做法实现最快,但从原理上说不严谨,因为模型的相对深度在整个范围内并不是严格线性的。实测数据显示,在2到8米的范围内,近似线性的误差普遍在10%到20%之间。
第二种是已知目标尺寸反推。如果检测的目标是车,且你统计算过这类车的平均宽度(比如家用轿车1.8米),那就用YOLO的框宽占图像比例反推距离:Z = 真实宽度 * fx / 像素宽度。这个公式不需要深度图,直接利用2D框宽度和目标物理尺寸。对车辆这种尺寸相对固定的目标可行,对人就麻烦,因为人的肩宽个体差异很大。
第三种是结合深度相机获取稀疏真值做回归。拿D435i或者激光雷达稀疏点云标定几个距离的真实深度,拟合一个二次多项式或者用中位数比例纠正。这是我推荐的方案,它同时解决了尺度偏移和非线性两个问题,代码量只多几行:
def linear_scale_recovery(relative_depth, real_points, model_points): """ real_points: 已知真实距离,单位米 model_points: 对应位置MiDaS输出深度 用最小二乘拟合 scale = sum(real * model) / sum(model^2) """ real_arr = np.array(real_points) model_arr = np.array(model_points) scale = np.sum(real_arr * model_arr) / np.sum(model_arr ** 2) return relative_depth * scale尺度恢复之后一定要做验证:在场景里摆几个已知距离的目标,打印预测距离和真实距离的误差。通常单目深度估计在5米以内能做到5%-10%的误差,超过10米误差会迅速膨胀。如果你做的项目精度要求超过这个范围,老实说,单目方案就不太合适了,应该直接上激光雷达或双目视觉。
5. 常见问题与避坑:我在这个方案上踩过的五个坑
5.1 深度图与RGB图对齐错位,导致远处目标的深度值取错
现象:目标明明在10米外,输出的Z坐标却只有6米,近处的反而偏大。排查后发现不是尺度恢复的问题,而是深度图直接resize导致和目标框对齐错位。
原因:MiDaS输出是低分辨率特征图,直接用cv2.resize把它拉回原图尺寸时,边缘和物体轮廓会往外“糊”一圈。目标越远、在画面里越小,深度图里对应区域的深度越容易被周围背景污染。
解决:不要对整张深度图做resize后再取值,而是先在低分辨率深度图上取目标框中心缩放后对应的坐标。因为MiDaS的输入是384x384,YOLO的框坐标先按比例映射到384尺度,在低分辨率深度图上取底边中点的深度,再转换回原图尺度。实测这个改动能把误差降一半以上。
5.2 相对深度模型没做尺度恢复直接输出“3D坐标”
现象:拿到MiDaS的深度图后,以为它就是真实距离,乘内参算出来的XYZ坐标完全不可用,目标看起来在3米,实际在15米。
原因:MixS、ZoeDepth这些模型在训练时使用相对深度标签,输出范围被归一化到0到1,不包含任何物理尺度信息。这是单目深度估计的基本属性,不是bug。
解决:训练前先确认模型类型。如果模型没有回归真实深度的头,一定要接尺度恢复模块。最省事的办法是给摄像头视野里放一个已知高度的杆子,用它校正比例。不要省这一步,这是整个方案里最后悔药都救不回来的坑。
5.3 小目标检测框的深度值抖动剧烈
现象:同一辆车静止停在路边,连续10帧输出的Z坐标在8到12米之间来回跳,轨迹画出来像锯齿。
原因:小目标在深度图里覆盖的像素很少,底边中点的深度值对像素偏移极其敏感。只要MiDaS在这一帧稍微把边界识别偏一点,深度值就会跳到背景物体上。
解决:三个手段叠加。一是取目标底部一块区域(比如底边向上10个像素高、左右各半个框宽)的中位数而不是单点;二是在时间维度做滑动平均,用最近5帧的Z值做滤波;三是限制相邻帧的最大变化量,如果Z值突变超过20%,大概率是检测框跳变或深度异常,直接沿用上一帧的值。
5.4 车载/监控场景下相机抖动导致深度突变
现象:室外监控相机在风吹下轻微晃动,算法输出的3D轨迹出现周期性波动。同一距离的目标一会儿近一会儿远。
原因:相机固定在外墙或立杆上,风吹导致微小角度变化。角度哪怕偏0.5度,10米处的目标位置就会偏几厘米到十几厘米。标定只解决静态误差,解决不了动态抖动。
解决:硬件上给相机加防震支架是最优解。软件上可以做帧间全局配准,或者用IMU数据实时修正外参。低成本方案是降低深度值的更新频率并加大滤波窗口的帧数,比如取50帧的中位数,代价是感知延迟增加。做车道级定位或安防预警这类不要求毫秒级响应的场景可以接受。
5.5 目标部分遮挡导致底边中点取错
现象:行人被路牌挡住一半时,YOLO框的底边已经不到人的脚部了,而是切在遮挡物前面,算出的Z方向偏移到遮挡物上。
原因:YOLO回归的是可见部分的外接矩形,不是目标的物理边界。遮挡场景下可见部分的外接矩形比真实框小,底边向上偏移。
解决:检测框后处理时加一个针对“着地类”目标的修正规则:当类别是person、car、bus时,将检测框向下扩展,直到碰到另一目标的框或图像边界。扩展量不要超过原框高度的15%,否则会把地面点也算进来。如果场景里遮挡频繁,建议换带遮挡评估的检测模型,或者把3D投影的点从框底边改为目标类别锚点(人取脚部中心,车取后轴中心)。
6. 验证精度与进阶优化:从能跑到能用
6.1 验证方法:用地面的真实距离做端到端误差测试
3D目标检测的精度不需要复杂的设备验证,一个卷尺加几个角点就能测出这个方案的真实水平。操作方法是:在相机视野里选3到5个测试点,量出它们到相机光心的实际距离,让算法输出每个点的预测Z值,然后算误差百分比。记录时注意目标要静止,检测框要稳定跟踪至少30帧,取平均Z值作为预测结果。表格里记录三个量:真实距离、预测距离、误差百分比。如果平均误差大于10%,方案只能用于粗略判断;小于5%才有资格做测距或定位类应用;要做机械臂抓取,需要重新评估是否该用单目方案。
6.2 进阶优化:TensorRT推理与关键帧跳帧
项目要上线,推理速度就是硬指标。YOLOv8n在GPU上跑没问题,但MiDaS的ONNX模型在CPU上跑384x384输入大约需要100到200毫秒一帧,这还不算YOLO的耗时。两个方案可以配合使用。
第一个方案是把两个模型都转成TensorRT,在NVIDIA显卡上用FP16精度推理,YOLOv8n的耗时能压到5到10毫秒一帧,MiDaS大约能到15到30毫秒一帧。转换代码的核心逻辑是先把ONNX转为engine文件,保存下来后续直接加载:
trtexec --onnx=midas_v21_small.onnx --saveEngine=midas_fp16.engine --fp16第二个方案是异步流水线。让YOLO始终跑全帧率,MiDaS深度估计只用在前一帧的结果上,目标框和深度错开一帧的偏差对慢速移动目标影响不大。交替执行两个模型的推理,整体帧率能提高40%以上。如果场景是安防监控,目标移动速度普遍不快,甚至可以把深度估计做成3帧更新一次,检测实时更新,这样CPU和GPU占用都能降下来。
最后一个优化点是把检测阈值和类别做场景化配置。固定场景里,代码里写死需要关注的目标类别和各自的距离范围,过滤掉距离超过阈值的检测框,可以有效降低误检和计算量。
说到这想提醒一句,整个方案最花时间的不是网络模型,而是尺度恢复和相机标定这两个看起来不起眼的环节。我第一次做这个项目时,跳过标定直接拿官方内参去算3D坐标,结果误差翻了十几倍,折腾了整整一周才发现是焦距参数不对。老老实实拿着棋盘格在办公室拍了一个小时,重新标定后所有数字都对了。做技术这东西,该走的踏实路一步都省不了,走捷径最后全变成冤枉路。希望我的这些经验对你有用。
本文还有配套的精品资源,点击获取