简介:ZED双目相机与YOLO目标检测结合的测距工程,面向计算机视觉开发者和机器人、自动驾驶等应用场景,提供一套可直接运行的双目测距代码,核心脚本为zedceju.py。项目集成ZED SDK深度信息与YOLO实时目标检测,可完成物体识别与距离计算,并涉及相机参数标定、模型加载、深度读取等关键环节,覆盖从环境配置到实际测距的完整流程。资源共42个文件,以20个Python脚本为主,同时包含YOLO配置与权重(2个cfg、2个weights)、CUDA源文件与头文件、Makefile、Markdown说明、XML工程配置等,压缩包约78.97MB,目录结构便于按功能定位代码。内容涵盖标定、检测、深度计算及相机调用等可执行脚本,并针对ZED SDK安装、numpy版本兼容等常见问题提供排错思路,适合基础扎实的中级开发者作为参考模板。已有3013人学习/下载,可直接运行或按需修改,便于快速搭建双目视觉测距demo并扩展至实际项目。
1. ZED+YOLO双目测距:为什么标定文件最容易翻车
“ZED + YOLO 双目测距”这个需求,听起来像接上相机、跑个YOLO、读一下深度图,屏幕上标个距离就完事。我实际帮别人复现这类项目时,最耗时的不是模型精度,而是测距数值的可靠性。ZED 出厂带标定参数,但它不是“免校准传感器”;YOLO 输出的检测框坐标要正确落到深度图上,中间隔着分辨率、通道格式、深度模式三层容易错位的环节。这篇笔记从双目测距原理讲起,到 zedceju.py 的完整链路拆解,再到标定、分辨率、显存三个大坑的排查记录,适合手里正好有 ZED、准备做目标检测加距离输出的从业者;还在观望方案选型的读者,也能从第 2 章的对比里找到判断依据。
2. 测距原理与选型:视差精度、深度数据通道与 YOLO 框对齐策略
2.1 视差公式:为什么 ZED 的“免标定”不能全信
双目测距的底层是三角测量。左右两幅图像上同一个物理点,横坐标差值为视差 d(单位像素),深度 Z 与视差 d 的关系是:
Z = f × B / d
其中 f 是相机焦距(像素单位),B 是左右目光心的基线距离。ZED 的基线是固定的 120mm,也就是 0.12 米。这个公式看着简单,把它对 d 求导就会得到一个关键结论:深度误差的放大系数正比于 Z²,反比于 f × B。这意味着距离越远,同样的视差误差会导致越大的深度误差,而且误差是按距离的平方恶化的。这不是玄学,是几何上的必然。
所以 ZED 在 5 米以内的深度输出通常挺稳,到了 12~15 米以后,哪怕左右图像素级别的视差变化,深度值都可能跳个半米。做机器人避障或者室内人员测距,使用范围控制在 0.5~8 米是比较舒服的区间。另外,ZED 的深度分辨率并非常说的“相同分辨率下深度图就是高清”,视差是在匹配块上算的,实际有效视差点数会明显低于 RGB 像素数。这也是为什么后面聊深度对齐时,我强调“别拿单个像素深度当真”。
“免标定”只意味着出厂时给了左右目内外参,已经不主动识别标定板了。但出厂参数对应的是一台刚下线的理想相机,经历运输震动、温度骤变、镜头磕碰后,左右目的相对位姿会漂移。ZED 提供了自校准工具,真要做严肃的测距输出,开箱后重标一次不是可选项,而是默认动作。
2.2 深度通道与 SDK 数据形态:你拿到的不是“直接能用的距离”
ZED SDK 返回的数据通道比普通双目开源方案多不少,常见的有:
- 左目 RGB 图,对应目测时的彩色画面;
- 右目 RGB 图,用来做视差计算和 3D 查看;
- 深度图(Depth map),float32 单通道,每个像素值代表该处物体在相机坐标系下的 Z 轴深度,单位是米;
- 点云,float32 四通道,依次是 X、Y、Z 和置信度,单位米;
- 置信图,用来评估每个深度值的可信程度。
这里有个容易踩坑的概念:深度图存的是 Z 分量,并不是“物体到这个点的直线距离”。当物体正好在相机光轴正前方时两者几乎一样,但只要物体偏移画面中心,例如出现在左上角,实际斜距就会比 Z 值大。普通避障场景用 Z 值够用,但如果你要输出给下游做路径规划,尤其涉及到碰撞半径的场合,就应该从点云里取三个坐标算欧氏距离。我一般都会把两个值同时算出来,界面上显示斜距,内部判断用 Z。
深度模式方面,SDK 提供了 PERFORMANCE、QUALITY、ULTRA、NEURAL 四档。这四档的计算开销和补洞能力差距很大。PERFORMANCE 速度最快,但在白墙、纯色地板这类弱纹理区域会频繁出现无效深度;NEURAL 模式是神经网络做深度补全,弱纹理区域的表现明显更好,代价是占用额外 GPU 显存和推理时间。实际做检测测距时,我会在低配工控机上用 QUALITY,桌面级显卡上再考虑 NEURAL。四种模式的选择可以参考下面的速查表。
| 深度模式 | 相对速度 | 弱纹理补洞能力 | 典型硬件 |
|---|---|---|---|
| PERFORMANCE | 最高 | 差,洞多 | 边缘设备、低功耗板 |
| QUALITY | 中等 | 中等 | 普通工控机 |
| ULTRA | 较慢 | 较好 | 带入门级独显的机器 |
| NEURAL | 最慢 | 最强 | CUDIA 显卡显存充足时推荐 |
2.3 检测框与深度对齐:先想清楚三个分辨率的关系
YOLO 输出的检测框坐标是相对于输入 RGB 图像的,所以要对齐深度图之前,必须先确认三个分辨率的关系:左目 RGB 图的分辨率、深度图的分辨率、以及 YOLO 推理时实际喂进去的输入尺寸。
ZED SDK 里可以分别设置 grab 分辨率和深度模式的分辨率。默认情况下深度图和 RGB 的分辨率是一致的,但如果你为了提帧率把深度计算降到了更低分辨率,比如 RGB 是 720p、深度图只有 640×360,那么 YOLO 框坐标直接套到深度图上就会完全对不准,测出的距离会来回跳。最省事的方法是初始化时把深度图分辨率固定成跟 RGB 完全一致,避免后续任何坐标变换。
YOLO 这侧还有一个隐藏坐标偏移:很多人加载模型后直接把摄像头帧丢进去推理,没考虑 YOLO 输入层会把图像 resize 到 416 或者 640,输出坐标又是映射回原图尺寸的。如果原图就是 ZED 的左目图像,坐标可以视为回到左目坐标系。只要 YOLO 的输入是同一帧左目图、没有额外裁剪,输出框和原图坐标是一一对应的。关键是别让图片在送入网络前悄悄经过了中心裁剪或者 letterbox,否则框位置在画面边缘会有几个像素的平移,距离误差不明显,但框会贴在目标物体边缘上。
3. 环境搭建与自校准:把 SDK 装好、把相机校准好再谈测距
3.1 SDK 安装与第一个深度画面:CUDA 版本和 USB 口先踩一遍
ZED 的依赖链路比普通 USB 相机长。给模拟项目X配置环境时,我习惯先建干净虚拟环境,避免和 OpenCV 的老版本互相污染。Python 3.8~3.10 这个范围内踩坑最少。安装命令如下:
# 创建虚拟环境,指定 Python 3.9 python -m venv zed_env source zed_env/bin/activate # 安装 ZED SDK 的 Python 绑定 pip install pyzed # 验证安装结果 python -c "import pyzed.sl as sl; print(sl.__version__)"如果最后一步报错,通常不是 Python 绑定的问题,而是 ZED SDK 本体没安装。pyzed 只是 Python 包装层,底层依赖 C++ SDK,需要先在官网申请下载对应系统版本的安装包。安装完成后,会在系统路径里自带官方的示例脚本,其中 zed_depth_viewer.py 是验证深度数据最常用的工具:
python -m zed_depth_viewer.py跑这个脚本应该能看到一个带深度伪彩图的窗口。这里提醒一句:ZED 相机必须用 USB 3.0 或者更高规格的接口,USB 2.0 口也能识别相机,但分辨率会强制降到 480p 级别,而且深度点非常稀疏。如果插上后系统只识别出摄像头但画面极其模糊,先检查是不是插到了机箱前面的 USB 2.0 口上。可以用下面这条命令确认连接速度:
lsusb -t看输出里有没有出现 5000M 或者 10000M 的速度标识。还在按 480M 跑的话,换一个主板后置 USB 口再试。
3.2 自校准的正确姿势:什么情况该重标,标定时标定板怎么放
自校准是 ZED 特有的工具,图形界面在 SDK 的 tools 目录下。启动后让相机对着标定板,软件自动采集多帧并计算出新的内外参。整个过程基本是傻瓜式的,但条件不对会一直失败。
第一次用之前,先把相机固定在支架上通电工作至少 10 分钟,让镜头和机身温度稳定下来再标定,否则热漂移会带来额外误差。标定板不要放太近,0.5 米以内左右目视角差太大,提取到的角点不稳定;也不要放太远,1 米到 1.5 米是最佳区间。标定板平面跟相机光轴之间的夹角尽量控制在正负 15 度以内,超过 20 度很容易失败。
光线条件比很多人以为的更重要。强光直射会让标定板上的白色区域过曝,角点直接丢失;光线太暗则黑白格反差不够。均匀的室内灯光下效果最好。我在某实验室复现时遇到过连续五次标定失败,把窗帘拉上一半、避免阳光斜射之后,一次就过了。
自校准成功后,结果会写进 ZED 本机的配置目录。如果后续要把代码部署到其他电脑,需要把这个配置文件一并带走,这个问题在第 5 章单独展开。
3.3 坐标、单位与点云换算:深度值和斜距不是一回事
ZED 的默认坐标系是:原点在左目摄像头光心,X 轴指向右,Y 轴指向下,Z 轴沿着相机朝向向前。硬件默认输出单位是米,SDK 初始化时也可以切成毫米,但统一用米最保险,和 YOLO 的检测结果、后续的下游算法都不会有单位换算负担。
深度图每个像素的数值就是该点在相机坐标系下的 Z 值。假设目标检测框内的所有像素深度有效,取中位数作为目标距离,得到的就是“目标正前方距离”,不是斜距。如果要输出到 HUD 或者避障系统里的欧氏距离,正确的做法是取点云:
import numpy as np # point_cloud 形状为 H x W x 4,依次是 X, Y, Z, 置信度 x = point_cloud[:, :, 0] y = point_cloud[:, :, 1] z = point_cloud[:, :, 2] # 逐像素计算斜距 distance_map = np.sqrt(x * x + y * y + z * z) # 只保留有效的 Z 值(大于 0) valid_mask = (z > 0.1)这里有一点要提醒:点云的无效点并不一定是 0,有时候是 NaN,有时候是极小的负值。做距离统计前先用np.isfinite()过滤一遍,别只判断大于零。测距结果能不能写进验收报告,往往就卡在这种细节上。
4. zedceju.py 拆解:初始化、YOLO 推理、检测框深度取值
4.1 相机初始化:分辨率、深度模式、单位怎么配
zedceju.py 整条链路从相机初始化开始。以我的项目习惯,720p 是性价比最高的分辨率:清晰度足够 YOLO 识别定位,深度计算的负担又比 1080p 小不少。初始化参数里,深度模式先选 QUALITY,等性能瓶颈出现了再往下调。
import pyzed.sl as sl # 创建 ZED 相机对象并配置初始化参数 init_params = sl.InitParameters() init_params.camera_resolution = sl.RESOLUTION.HD720 # 1280x720,测距够用 init_params.depth_mode = sl.DEPTH_MODE.QUALITY # 速度和补洞效果折中 init_params.coordinate_units = sl.UNIT.METER # 深度和点云单位设为米 init_params.camera_fps = 30 # 常用 30,后续可按需降 zed = sl.Camera() status = zed.open(init_params) if status != sl.ERROR_CODE.SUCCESS: print("相机打开失败,错误码:", status) exit(1) # 提前确认深度图分辨率,后续坐标对齐全靠这个值 runtime_params = sl.RuntimeParameters() depth_mat = sl.Mat() zed.retrieve_measure(depth_mat, sl.MEASURE.DEPTH) print("深度图分辨率:", depth_mat.get_width(), "x", depth_mat.get_height())到这里要解释两个容易误用的参数。camera_resolution设成 HD720 后,左目 RGB 就是 1280×720;camera_fps如果设太高,比如 60,深度计算会消耗更多算力,反而拖慢整体帧率。最后打印深度图分辨率这一步非常关键,别删掉,它会在你后面排查错位问题时直接给答案。
4.2 YOLO 模型加载与推理:轻量模型和坐标映射的取舍
YOLO 侧我用的是 ultralytics 提供的 YOLOv8 轻量权重 yolov8n.pt。做检测测距这类任务,模型体积和显存占用比精度更优先,检测框本身就是用来圈深度采样区域的,不需要特别精细的分割级别精度。
import cv2 import numpy as np from ultralytics import YOLO # 加载 yolov8n 轻量模型,显存友好,727fps 的显卡也跑得动 model = YOLO("yolov8n.pt") # 推理参数:置信度 0.45,NMS 阈值 0.5 model.conf = 0.45 model.iou = 0.5 # 用 416 作为输入尺寸,比默认 640 省一半以上的计算量 # 注意 imgsz 会影响检测框在边缘位置的表现,太小会漏检 image = cv2.imread("frame.jpg") image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) results = model(image_rgb, imgsz=416, verbose=False) boxes = results[0].boxes.xyxy.cpu().numpy() # 四角坐标,浮点数 labels = results[0].boxes.cls.cpu().numpy().astype(int) scores = results[0].boxes.conf.cpu().numpy()imgsz=416是个值得说道的参数。YOLO 官方默认输入是 640,对大半屏的小目标更友好,但每帧推理耗时也接近翻倍。在我们的测距场景里,目标是行人和车辆这类中大型物体,416 下检测框会略松一点,但不影响深度采样。如果你检测的是远距离小物体,比如 15 米外的小锥桶,建议把 imgsz 调回 640,用帧率换召回率。
结果里的boxes坐标映射回的是输入图像尺寸,也就是 640×480 的 BGR 帧本身。这一段代码在连续帧循环里要复用同一个 model 对象,不要每帧重新加载权重,否则帧率会掉到个位数。
4.3 检测框内深度采样:取中位数而不是中心点
深度对齐是这套代码的命中要害。很多教程写“取检测框中心点的深度值”,实测非常不稳定:中心点可能刚好落在目标的缝隙上,或者落在边缘的高反差区域,深度值瞬间跳变。我会在检测框内部取一个采样区域,用中位数滤波去掉离群点。
# 获取当前帧的深度图,数据是 H x W 的 float32 数组,单位米 depth_data = depth_mat.get_data() # 遍历每一个检测框,计算框内有效深度 for box in boxes: x1, y1, x2, y2 = box.astype(int) # 框越界保护 x1 = max(x1, 0) y1 = max(y1, 0) x2 = min(x2, depth_data.shape[1] - 1) y2 = min(y2, depth_data.shape[0] - 1) # 截取检测框对应区域 region = depth_data[y1:y2 + 1, x1:x2 + 1] # 过滤无效深度:ZED 无效点返回 0 或 NaN valid = region[np.isfinite(region)] valid = valid[valid > 0.1] # 0.1 米以下的近端噪声排除 if valid.size == 0: continue # 用中位数而不是均值,避免单帧误匹配点拉偏整个结果 dist = float(np.median(valid)) print(f"检测框中心距离:{dist:.2f} m")两个关键点。第一,为什么用中位数:深度图在物体边缘处经常混入背景值,均值会被几个极近或极远的错误点带偏,中位数对这类离群噪声有天然抵抗力。第二,为什么阈值过滤大于 0.1 米:ZED 的近距极限在 0.3 米左右,小于 0.1 米的值基本都是无效数据。如果你工作的场景有透明物体或者反光表面,可以把区域再缩小到检测框中心的 50% 区域,进一步避开边缘污染。
4.4 主循环:grab、推理、显示、性能统计
主循环里的核心原则是“每一帧只抓一次图、只推理一次”。常见翻车写法是把zed.grab()放在 while 循环里,每次还重新读取一次左目图,然后再跑 YOLO,导致同一帧被处理两遍,帧率白白减半。
while True: if zed.grab() != sl.ERROR_CODE.SUCCESS: continue # 取左目 RGB 帧 left_image = sl.Mat() zed.retrieve_image(left_image, sl.VIEW.LEFT) frame = left_image.get_data() # 已经是 numpy 数组,BGR 顺序 # 取深度图,与左目图像分辨率保持一致 zed.retrieve_measure(depth_mat, sl.MEASURE.DEPTH) # 送入 YOLO 推理,返回检测框 results = model(frame, imgsz=416, verbose=False) boxes = results[0].boxes.xyxy.cpu().numpy() # 按上一小节的方式计算每个框的距离 # 然后绘制检测框和距离文本 if cv2.waitKey(1) & 0xFF == ord('q'): break zed.close() cv2.destroyAllWindows()性能统计这里我习惯用一个简单的 FPS 计时器,每 30 帧计算一次平均帧率,打印到终端。这样在调分辨率或者换深度模式时,能立刻看到改动带来的效果。要注意get_data()返回的数组可能带负 stride 或者内存不连续,做推理前用np.ascontiguousarray(frame)兜底,避免某些版本的深度学习框架报错。
5. 常见问题与避坑:ZED+YOLO 测距最典型的五个翻车点
5.1 CUDA 显存不足:初始化顺序和分辨率选择的血泪经验
现象:zedceju.py 能启动,跑 5~10 帧后程序闪退,终端报 CUDA out of memory。
原因:ZED 自己有深度计算占显存,YOLO 又加载一份模型权重,再加上输入图像的检测中间结果,显存不够时最先崩掉的就是 Python 进程。很多人在初始化 ZED 时直接上 1080p 加 NEURAL 模式,显卡显存直接爆掉。
解决:把相机分辨率降到 HD720,深度模式从 NEURAL 降到 QUALITY,YOLO 用 yolov8n 而不是 yolov8m,imgsz从 640 降到 416。这套组合在常见的单卡 8G 显存机器上能稳定跑到 20 帧以上。如果还不够,在每轮循环推理结束后手动释放缓存:
import torch # 放在每次 YOLO 推理之后 results = model(frame, imgsz=416, verbose=False) torch.cuda.empty_cache()注意empty_cache()是释放未使用的缓存块,不是强制释放仍在使用的显存。瓶颈在模型权重本身时,这个方法无效,只能减小模型。
5.2 距离“焊死”在 3 米:深度图和 RGB 分辨率没对齐
现象:画面里的物体从 1 米移到 6 米,输出距离变化很微弱,甚至稳定在 3 米左右跳动,检测框和物体位置明显错位。
原因:深度图分辨率小于 RGB 分辨率。比如 RGB 是 1280×720,深度图实际只有 640×360,检测框坐标直接索引到低分辨率深度图后,采样区域覆盖了完全不同的空间范围,距离自然不对。
解决:把第 4.1 节里打印深度图分辨率的代码保留下来。如果看到深度图宽高是 RGB 的一半,在初始化参数里手动指定深度和图像分辨率一致,或者在做区域采样前把深度图 resize 回 RGB 尺寸:
# 深度图低分辨率时,先 resize 到 RGB 尺寸再采样 depth_resized = cv2.resize(depth_data, (frame_width, frame_height), interpolation=cv2.INTER_NEAREST)这里用最近邻插值而不是双线性插值。双线性会把边缘无效深度值“糊”到有效区域里,产生虚假的距离读数。
5.3 自校准一直失败:标定板的摆放和光线是关键
现象:校准工具运行到一半提示失败,或者完成后深度图出现诡异扭曲,墙面都变成波浪形。
原因:标定板距离不合适、倾斜角度过大、环境光直接照射造成局部过曝。有时校准结果本身是错的,但工具没报错,只是把错误的参数写进了配置,这时候深度图全盘出错。
解决:标定板放到 1 米到 1.5 米位置,倾角控制在正负 15 度内,避免阳光直射板面。校准前先让相机通电 10 分钟。如果深度图看起来已经异常,先重置出厂参数再重新标定,而不是在错参数上反复覆盖。重置方法在 ZED SDK 工具的 settings 面板里有恢复出厂校准的按钮。
5.4 帧率只有 7 帧:同步抓图加同步推理是最大瓶颈
现象:按下运行后输出不到 10 FPS,画面上物体的移动都像慢动作。
原因:每帧循环里先 grab 同步等待,再取左目图,再做深度计算,最后跑 YOLO。所有这些步骤都串行执行,GPU 在 ZED 和 YOLO 之间来回切换,上下文切换开销非常大。ZED 的 grab 本身是阻塞式的,默认会等到底层深度处理完成。
解决:把 YOLO 推理频率降低到 10~15 FPS,深度和显示保持 30 FPS。具体做法是用交替检测策略:
frame_count = 0 while True: if zed.grab() != sl.ERROR_CODE.SUCCESS: continue frame_count += 1 if frame_count % 2 == 0: # 每两帧才跑一次 YOLO,距离跟随用上一帧的检测框 results = model(frame, imgsz=416, verbose=False) last_boxes = results[0].boxes.xyxy.cpu().numpy() # 用 last_boxes 计算距离,没有新检测框时沿用上一次的结果这个策略在低速移动场景下精度几乎无损,帧率却能翻倍。
5.5 换机器后距离全乱:记得备份 conf.xml 并手动加载
现象:同一套代码、同一个相机,在这台电脑上测距准确,换一台机器后距离偏差明显,有时甚至出现镜像般的错乱。
原因:自校准结果保存在本机配置目录里的 conf.xml,它没有跟着工程代码走。换机器后 ZED SDK 没找到对应的校准文件,只能用默认出厂参数运行,但相机已经被重新校准过,参数对不上。
解决:把校准文件复制到工程目录里,初始化时手动加载:
init_params = sl.InitParameters() # 显式指定校准文件路径,而不是依赖本机默认设置 init_params.set_from_calibration_file("conf.xml")从那以后我每次换机器,都会强制先走一遍“备份 conf.xml、跑一次三距离误差验证、再调分辨率”这个流程。更稳妥的做法是把 conf.xml 和 zedceju.py 放在同一个目录,用相对路径引用,这样工程整体拷贝到任何机器都不会丢标定参数。
6. 落点验证:用已知距离把测距误差钉死在纸面上
代码能跑通只是第一步,距离准不准必须用实测说话。我的验证方法是在室内走廊贴一排标记,分别放在 2 米、3 米、5 米、8 米的位置,让相机正对标记,在每个位置连续采集 50 帧,记录 zedceju.py 输出的距离值,再统计中位数和标准差。这里用的是中位数,不用均值,因为实测中偶尔会有几帧深度完全失效,均值会被拉飞。
以我手上的 ZED 在 QUALITY 深度模式下的数据为例,接近如下表现:
| 实际距离 | 输出中位数 | 标准差 | 误差 |
|---|---|---|---|
| 2.00 m | 2.05 m | 0.04 m | +2.5% |
| 3.00 m | 3.08 m | 0.06 m | +2.7% |
| 5.00 m | 5.20 m | 0.12 m | +4.0% |
| 8.00 m | 8.40 m | 0.21 m | +5.0% |
误差随距离增大而变大的规律和视差公式吻合,不是代码 bug。如果某个距离点的误差突然跳到 15% 以上,优先怀疑那个位置的纹理条件太差,或者检测框边缘切入了背景区域。
最后的输出端,我会在原始距离基础上加一个滑动中位数滤波器,抑制单帧抖动:
import collections import numpy as np # 滑动窗口长度设为 10,约 0.3 秒的平滑粒度 dist_history = collections.deque(maxlen=10) # 每帧计算得到新距离后 dist_history.append(current_dist) filtered_dist = float(np.median(dist_history)) # 显示时用 filtered_dist 代替当前帧原始值为什么用滑动中位数而不是滑动平均?因为深度图偶尔会蹦出一个完全错误的离群值,均值会被明显拉走,中位数能保持稳定。窗口长度 10 在 30 FPS 下大约 0.3 秒延迟,对避障和人员追踪场景都够用。窗口太长会导致测距结果反应迟钝,目标从 1 米快速走近到 60 厘米时,输出距离会滞后近半秒,这个手感要自己拿捏。
这套验证流程跑完之后,把误差表贴到工程 README 里,比任何调试话术都更有说服力。从那以后我每次换环境、换机器,都会强制走一遍“备份 conf.xml、跑三距离误差验证、再调分辨率”三步,能省掉很多无意义的调试。希望帮到你。
本文还有配套的精品资源,点击获取