
简介面向自动驾驶、机器人导航与安全监控等场景项目源码演示了如何将YOLO二维目标检测与深度估计技术相结合实现从图像到三维空间的定位与测距。包内共7个文件以5个Python脚本为核心涵盖相机参数加载、深度估计模型、检测模型、三维边界框工具及主运行流程另附依赖清单与说明文档压缩包仅20KB结构精简、便于快速阅读与二次开发。目前已有118人浏览学习适合具备一定深度学习基础、希望将YOLO扩展至三维检测的算法工程师与研究人员。项目中包含完整的模型构建与训练流程通过阅读和运行源码可以掌握深度估计与检测结果的融合方式、三维边界框的解析与可视化方法并了解依赖安装、模型评估及调参思路为实际工程部署或学术研究提供可直接落地的参考起点。1. 这年头做3D检测为什么还有人从YOLO深度估计入手很多刚接触3D目标检测的人一上来就被PointNet、VoxelNet、CenterPoint这些名字劝退要装CUDA版PyTorch要下几十GB的KITTI原始数据要调点云预处理参数折腾一礼拜连训练脚本都没跑通。而“基于YOLO深度估计”这条路线是把一个成熟的2D检测器和一个预训练好的深度估计模型拼起来先用YOLO把画面里的目标框出来再用深度估计网络给每个像素一个深度值最后把2D框结合深度信息投影到三维空间得到目标的距离和尺寸。它不需要激光雷达不需要GPU集群一张普通显卡甚至CPU都能推理数据标注也只要2D框。对课程设计、毕业设计、技术预研和产品原型验证来说这是性价比最高的入口。这篇笔记就按我自己做这个方案的顺序把网络选型、数据准备、关键代码、坑点和进阶方向一次讲清楚。2. 方案选型与核心原理为什么是YOLO为什么是单目深度估计2.1 YOLO家族的选型逻辑不是越新越好YOLO走到今天已经迭代了很多版本从v3、v5到v8、v9甚至v11但做3D目标检测的2D前端选型逻辑和纯2D检测不完全一样。YOLO在这里只负责输出类别、2D边界框和置信度——它的任务很纯粹。我以前用YOLOv5做过一版后来换成YOLOv8最大的感受是v8的anchor-free设计省掉了聚类anchor的步骤换数据集时少一个坑而且v8的export接口统一转TensorRT或OpenVINO都方便。选型时需要看两条硬指标。第一是推理帧率3D检测往往要接后续的目标跟踪或测距逻辑2D检测最好控制在10ms以内给深度估计和后处理留出余量。第二是误检率2D检测一旦多框或漏框3D投影后的误差会被放大后面接测距的话会直接导致距离跳变。所以不要一味追求mAP最高的模型要选在你目标场景上误检最少的。我自己在园区车辆检测场景里试过v8s比v8m的mAP低了不到两个点但推理速度快了将近一倍最后生产环境选的是v8s。2.2 深度估计的技术路线绝对深度与相对深度深度估计这块目前主流方案分两类。第一类是绝对深度估计直接回归每个像素到相机的真实距离代表模型有MiDaS、DPT、Depth Anything。第二类是相对深度估计只输出深度图各像素之间的相对大小关系需要额外标定才能换算成真实距离。3D目标检测必须用绝对深度因为最终要算目标的真实距离和三维位置。这里有个常见的概念混淆单目深度估计和双目深度估计不一样。双目靠左右视图视差算深度精度高但需要双摄像头硬件单目只靠一张RGB图本质上是“猜”出来的。单目做近距离比如5米以内的测距还能用距离一远误差就指数级增长。所以标题里这种方案定位很明确适用于中近距离、车道或园区这样的结构化场景。如果你要做100米外的车辆测距这个方案不靠谱得老老实实上激光雷达或双目。深度估计模型本身也有轻量级选择比如Depth Anything的ViT-S版本在CPU上也能跑到实时。2.3 2D框到3D位置的转换相机模型是小孔成像的数学化YOLO给出2D框之后怎么变成3D位置这里要过一遍相机模型。一个空间点 P(x, y, z) 通过针孔相机投影到图像平面公式是z * [u, v, 1]^T K * [R|t] * [x, y, z, 1]^T。其中 K 是相机内参矩阵焦距 fx、fy 和光心 cx、cyR 和 t 是相机外参。反过来已知图像坐标 (u, v) 和深度 z可以通过公式还原出空间坐标。实际工程中YOLO框住的目标不是一个点而是一个面。常见做法是取2D框底边中心点的像素坐标配合该点的深度估计值再结合相机安装高度和俯仰角算出目标到相机的水平距离。这也是为什么YOLO深度估计能做“伪3D”检测它输出目标中心在相机坐标系下的位置、2D框的宽高再按目标的类别先验尺寸加一个朝向角估计就能在BEV视角画出目标的矩形轮廓。这套做法在自动驾驶领域有个专门的名字叫“单目3D检测”性能天花板比不过激光雷达方案但胜在部署成本极低。3. 从零搭一套可运行的YOLO深度估计3D检测代码结构与关键模块拆解3.1 整体架构设计一条流水线上只有三个模块拿到源码包先别急着跑训练把代码结构看明白比什么都重要。一个好的YOLO深度估计项目通常拆成三个独立模块2D检测模块、深度估计模块、后处理融合模块。三个模块之间用标准数据接口通信避免耦合。我之前接过一个源码包检测和深度估计写在一个脚本里改一个模型就要动另一个模型的加载逻辑维护成本极高。后来我自己重构把目录拆成这样project/ ├── configs/ # 配置文件模型参数、相机参数、类别映射 ├── models/ │ ├── detector.py # YOLO检测器封装 │ ├── depth_estimator.py # 深度估计模型封装 │ └── fusion.py # 2D框 深度 → 3D位置 ├── data/ # 测试图片、视频、标定文件 ├── utils/ │ └── camera.py # 相机标定与坐标系转换 ├── scripts/ │ ├── train_detector.py # 训练YOLO检测器 │ ├── infer.py # 端到端推理入口 │ └── evaluate.py # 3D测距精度评估 └── checkpoints/ # 预训练权重整个流程一句话概括读入一帧图像 → YOLO输出2D框和类别 → 深度估计网络输出同分辨率深度图 → fusion模块把2D框中心坐标结合深度值换算成3D坐标再按类别先验尺寸生成BEV下的3D框。下面按模块拆开看代码。3.2 用YOLOv8封装2D检测器20行代码跑通推理YOLOv8的推理接口已经很成熟直接用ultralytics库就能封装。如果你入手的是老版本源码检测部分可能是Darknet结构但接口思路是一样的——先加载权重再前向推理最后解析输出。import cv2 import numpy as np from ultralytics import YOLO class Detector2D: def __init__(self, model_path, conf_thres0.5): # 加载预训练权重只用来做2D检测 self.model YOLO(model_path) self.conf_thres conf_thres # 置信度阈值低于该值的检测框会被丢弃 def detect(self, image_bgr): # 输入BGR图像输出[[x1, y1, x2, y2, conf, cls_id], ...] results self.model.predict( sourceimage_bgr, confself.conf_thres, verboseFalse, imgsz640 # 输入分辨率越高越准但越慢 ) boxes results[0].boxes dets [] if boxes is not None: xyxy boxes.xyxy.cpu().numpy() # 左上角/右下角坐标 confs boxes.conf.cpu().numpy() # 置信度 clses boxes.cls.cpu().numpy().astype(int) # 类别ID for x1, y1, x2, y2, c, cls_id in zip(xyxy[:, 0], xyxy[:, 1], xyxy[:, 2], xyxy[:, 3], confs, clses): dets.append([float(x1), float(y1), float(x2), float(y2), float(c), int(cls_id)]) return np.array(dets) if dets else np.empty((0, 6))这段代码里有个容易被忽略的点model.predict里设了imgsz640这会改变输入图像的缩放尺寸。如果你的测试图片分辨率是1920x1080模型内部会先缩放到640x640检测框坐标再映射回原图。这个映射在ultralytics内部已经处理好了但如果你换成其他检测框架要确认输出坐标是在哪个分辨率下的否则3D融合时坐标全部偏移这是最常翻车的地方。3.3 深度估计模块封装预训练模型直接用深度估计这块如果不是特别追求性能直接用开源预训练模型最省事。Depth Anything是一个不错的选择它基于DPT架构训练单目输出绝对深度而且有轻量版本NVIDIA Jetson这类边缘设备也能跑起来。下面是封装示例import torch import numpy as np import torchvision.transforms as transforms from PIL import Image import torch.nn.functional as F class DepthEstimator: def __init__(self, model_path, devicecuda): # 加载预训练深度估计模型 self.device device self.model torch.load(model_path, map_locationdevice) self.model.eval() # 图像预处理归一化 缩放到模型输入尺寸 self.transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ) ]) def estimate_depth(self, image_bgr): # 输入BGR图像输出深度图单位米 image_rgb cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) img_tensor self.transform(Image.fromarray(image_rgb)).unsqueeze(0).to(self.device) with torch.no_grad(): depth self.model(img_tensor) # 深度图插值回原图尺寸 depth F.interpolate(depth, size(image_bgr.shape[0], image_bgr.shape[1]), modebilinear, align_cornersFalse) return depth.squeeze().cpu().numpy()这段代码要注意两个参数Resize的尺寸直接影响精度和速度的平衡224x224能实时但深度细节差调到384x384精度好一些但推理时间翻倍depth model输出的深度单位不一定是米不同预训练模型差异很大有的输出归一化值0到1有的输出视差值必须先做验证否则后续3D坐标换算全都是错的。我之前接手过一个项目深度图输出范围是0到255直接当米数用测出来的距离全偏大几十倍排查了大半天才找到这个坑非常值得记录。3.4 后处理融合从像素坐标到3D坐标的数学落地融合模块是整个项目最核心的部分也是最需要数学功底的。先要明确输入相机内参矩阵K、2D检测框、该框底边中心点的像素坐标和对应深度值。输出目标在相机坐标系下的位置。这里用最简单的针孔模型演示import numpy as np class Fusion: def __init__(self, camera_intrinsics, camera_height1.5, camera_pitch0): # camera_intrinsics: [fx, fy, cx, cy] # camera_height: 相机离地高度米用于换算水平距离 # camera_pitch: 相机俯仰角弧度0表示水平 self.fx, self.fy, self.cx, self.cy camera_intrinsics self.camera_height camera_height self.pitch camera_pitch def pixel_to_3d(self, u, v, depth): # 单点从像素坐标转相机坐标系坐标 x (u - self.cx) * depth / self.fx y (v - self.cy) * depth / self.fy return np.array([x, y, depth]) def box_to_3d_position(self, box, depth_map): # box: [x1, y1, x2, y2, conf, cls_id] # 取2D框底边中心点代表目标与地面的接触点 x1, y1, x2, y2 box[:4] u_bottom (x1 x2) / 2.0 v_bottom y2 # 从深度图取该点的深度值取附近像素的均值更稳 depth depth_map[int(v_bottom), int(u_bottom)] x, y, z self.pixel_to_3d(u_bottom, v_bottom, depth) # 考虑相机俯仰角换算水平距离 if abs(self.pitch) 0: z z * np.cos(self.pitch) - y * np.sin(self.pitch) horizontal_dist np.sqrt(x**2 z**2) return (x, y, z, horizontal_dist)这段代码里有个在工程上很重要的细节为什么取框底边中心点而不取框中心因为底边通常是目标与地面的接触点比如车辆轮胎位置这个点的高度更接近地平面换算距离时受目标高度影响更小。另一个细节是深度值取单点还是取邻域均值——我建议取底边中心周围3x3或5x5区域的中值深度估计输出在边缘处噪声大单点取值容易跳变。后续做目标跟踪时距离信息稳定的重要性怎么强调都不过分。3.5 端到端推理脚本把三个模块串起来模块写好后推理入口脚本很简单核心是做组合调用。注意视频流场景要做帧率控制否则检测深度估计两个模型叠加推理帧率可能掉到个位数。import cv2 from detector import Detector2D from depth_estimator import DepthEstimator from fusion import Fusion # 初始化三个模块 detector Detector2D(yolov8s.pt, conf_thres0.5) depth_estimator DepthEstimator(depth_anything_vits.pth, devicecuda) fusion Fusion(camera_intrinsics(800.0, 800.0, 640.0, 360.0), camera_height1.5, camera_pitch0) cap cv2.VideoCapture(test_video.mp4) while cap.isOpened(): ret, frame cap.read() if not ret: break # 1. 2D检测 dets detector.detect(frame) # 2. 深度估计 depth_map depth_estimator.estimate_depth(frame) # 3. 逐目标融合 results_3d [] for box in dets: x, y, z, dist fusion.box_to_3d_position(box, depth_map) results_3d.append({ box2d: box[:4].tolist(), class_id: int(box[5]), position: (round(x, 2), round(y, 2), round(z, 2)), distance: round(dist, 2) }) # 可视化画2D框和距离标签 for res in results_3d: x1, y1, x2, y2 res[box2d] cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) label f{res[class_id]} {res[distance]}m cv2.putText(frame, label, (int(x1), int(y1)-10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.imshow(3D Detection, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()整个推理链路是拿到一帧图像 → 走一遍2D检测拿目标框 → 走一遍深度估计拿全图深度 → 对每个框取底边中心深度值 → 用相机内参反投影到3D坐标 → 计算水平距离。这条链路里最容易拖慢速度的是深度估计部分Depth Anything的ViT-S版本在RTX 3060上大约20ms一帧加上YOLO的10ms整体30ms左右可以做到30FPS。如果你用的是更大的ViT-L版本单帧可能要大几十ms就要考虑降分辨率或换模型。4. 深度估计的精度陷阱与YOLO检测的边界问题避坑集4.1 深度图单位不统一纳米级误差还是几米级误差现象距离计算结果和实际值差了好几个数量级。原因不同开源深度估计模型输出的单位完全不同。MiDaS系列输出的是视差值disparityDepth Anything部分版本输出是相对深度还有的模型输出的深度图数值范围是0到255需要除以255再乘最大距离才是真实米数。直接拿数值当米用结果自然是天差地别。解决拿到预训练模型的第一步不能用一张包含已知距离的图片做验证。我常用的方法是放一把卷尺在画面里相机距卷尺1米、3米、5米分别拍三张图跑一次深度估计对比输出深度值和真实距离的映射关系。在batch脚本里把这个验证写成流程的一部分换模型时能立刻暴露单位问题。4.2 2D检测框不准导致3D距离跳变现象目标明明匀速走近距离数值却忽远忽近像在跳变。原因3D位置计算依赖2D框底边中心点的像素坐标一旦YOLO输出的框上下抖动几个像素在深度图上取的深度值就会变化。尤其是当底边落在深度估计误差大的区域如反光车窗、车辆轮胎阴影时距离跳变会被放大。解决第一招是时间域滤波对连续帧的距离做EMA平滑alpha取0.3到0.5第二招是空间域处理取底边中心周围5x5区域的中值深度再加深度一致性校验——当前帧深度和上一帧深度差超过30%就丢弃等下一帧再更新。这套组合在实测中能把距离跳变幅度减小大约六成。我实际测试下来这两招叠加效果最明显。4.3 近距离遮挡导致深度估计失效现象目标靠近时距离被高估越近越不准。原因单目深度估计在近距离1米内时由于目标占画面比例大且常出现遮挡边界模型难以从上下文推断出准确的深度关系。深度的“绝对数值”和“相对关系”在近距离时反而更容易错乱。解决把近距离场景独立处理比如目标底边对应的深度值低于0.5米时直接用先验尺寸估算法。对车辆这类有固定尺寸的目标用2D框宽度相机焦距目标类别平均宽度反推距离。公式是距离 焦距 * 目标实际宽 / 2D框宽。这个公式在近距离比深度估计可靠。特别是对车宽2米左右的轿车这个反推误差在日常使用中表现不错。4.4 光照变化与地面对应点丢失现象夜间或强逆光下距离误差明显增大YOLO检测框还在但深度图在目标底部出现大块黑色异常区域。原因单目深度估计对图像质量敏感。夜间光线不足地面纹理缺失模型找不到可靠的深度线索强逆光时目标底部被阴影包围深度估计值通常偏向背景远距离。解决自动切换策略。如果深度图在目标底边区域的标准差超过阈值说明该区域深度估计不可信改走类别先验尺寸反推。同时建议在夜间场景换用带红外补光的摄像头RGB图像质量上去了深度估计精度才会跟着提升。做夜间场景之前要有一个心理预期单目深度估计在夜间的精度下降是模型天花板决定的白天能到5%误差夜间可能直接跌到20%以上。5. 从能跑到能用的进阶之路模型轻量化、精度验证与场景适配经验这套方案做到“能跑”很容易但要做到“能用”还差几步。首先是模型轻量化YOLO和深度估计两个模型叠加对边缘设备的算力要求并不低。我在Jetson Orin Nano上做过一次部署YOLOv8s大约5ms一帧Depth Anything ViT-S大约30ms整体跑不到实时。后来把深度估计模型做了TensorRT FP16转换再把输入分辨率从384降到256整帧延迟压缩到25ms以内勉强达到实时要求这说明即使损失一点精度也要先保证帧率能看否则工程上没法用。其次是精度验证方法。很多源码包只教你怎么跑通推理不教你验证结果对没对。我做这个项目时自己搭了一套验证方案准备10组不同距离的标定场景每组场景里有明确已知距离的车辆或行人用程序自动对比预测距离和真实距离算平均绝对误差MAE和均方根误差RMSE。验证结果要落在指标上能看懂的程度——比如5米内MAE小于0.3米10米内MAE小于0.8米——如果超出这个量级就说明相机标定或深度图映射里还有问题。第三是场景适配经验。这套方案的精度上限取决于目标类型和种类的选择。我踩过一个具体的坑在行人多的场景里行人的2D框长宽比变化极大底边中心点落在脚部位置但行人的脚部在深度图上往往和地面融合在一起深度值偏大。后来我改成了取2D框底部1/10区域的深度均值而不是单点效果好了很多。另一个是车道场景中间车道的车辆检测精度远高于两侧车道因为两侧车辆在画面里是斜着的2D框底边中心点对应的地面位置和相机之间的连线穿过了车身侧面深度被高估。这是视角导致的结构性问题解决不了只能靠多相机覆盖来做融合这也是方案本身的边界。最后一件事是把2D深度这样的方案扩展成真正能在项目里交付的东西。我现在的习惯是训练阶段就用YOLOv8系列部署阶段再用YOLOv8导出engine文件中间不做跨框架切换深度估计模型优先选Depth Anything这类有持续维护和预训练权重的模型不碰学术repo里“论文一发了之”的代码。这套组合我前后在三个不同项目里复用换数据集时只需要重新训练YOLO的检测头深度估计模型保持不变省下来的时间不可估量。希望这些经验能帮到你少走一段弯路。本文还有配套的精品资源点击获取