简介:一套面向计算机视觉与机器人感知学习者的ZED相机结合YOLO双目测距工程实践包,重点解决立体相机目标检测与目标距离估算的联调问题。压缩包共42个文件、约78.97MB,以20个Python脚本为核心,配合5个XML工程配置、2个cfg与2个weights模型文件,以及CUDA层源码、Makefile、说明文档和演示GIF等,覆盖检测推理、相机调用、可视化与工程构建等模块。目录中包含可直接运行的zedceju.py主程序,以及zed.py、zed_trt.py、yolo_with_plugins.py、size.py等辅助脚本和YOLOv4、YOLOv4-tiny权重,便于读者对照梳理ZED SDK接入、numpy版本兼容、TensorRT加速与距离计算之间的工程组织方式。已有3016人学习,适合具备一定Python和OpenCV基础、希望动手理解双目测距完整链路的开发者参考。
1. 一台相机两个镜头:zedceju.py 到底在算什么
把 ZED 往桌上一架,运行zedceju.py,屏幕上每个检测框右上角就会跳出一个以米为单位的数字。这件事说起来就一句话:ZED 双目相机出深度图,YOLO 出目标框,脚本把框和深度对齐,给每个目标贴上一个绝对距离。整套逻辑全压在一个 Python 文件里,没有 Web 服务、没有数据库、没有训练流程,是那种拿来就能改、改完立刻能看见效果的小工程。
它适合两类人:一类是刚拿到 ZED,想先确认相机、SDK、深度链路都是通的;另一类是在做机器人避障、客流统计、工位安全距离告警这类活,需要「检测框 + 米制距离」这个最小可用单元,然后往上叠业务。要注意它不是高精度测量仪,它是把 ZED 的物理基线和 YOLO 的语义框拼在一起,给你一个能直接用的、有量纲的目标距离。
2. 选型对照:为什么用 ZED 的双目深度配 YOLO,而不是单目估距
先把这个组合的合理性说清楚,不然后面调参全是玄学。测距这件事有三条常见路线,各自的坑完全不一样,选错了后面怎么调都别扭。
2.1 三条测距路线的现场对比
最省事的是「单目 + 已知物体尺寸」。框的像素宽高对应真实宽高,按相似三角形反推距离。听起来很美,实际用起来一地鸡毛:同一类目标尺寸方差极大,人站直和半蹲能差出 40%,框还会因为遮挡被截断一半,算出来的距离直接翻倍。这种方案只在「目标尺寸高度固定 + 视角稳定」的产线场景勉强能用,换个场地就得重新标定。
第二条是「单目深度估计网络」,输出一张稠密深度图。视觉上很惊艳,但它是相对深度,尺度是漂的,同一个场景换一批数据,整体乘个系数才勉强对得上。做演示可以,做告警阈值不行,因为你没法给「2 米」定一个稳定阈值。
第三条就是 ZED 走的路:双目立体匹配。两个镜头有固定的物理基线 B,同一个物点在左右图上的横坐标差(视差 d)和距离成反比,Z = fx * B / d。这里 fx 是焦距(像素单位),B 是基线(米),d 是视差(像素)。整条链路里 fx 和 B 都来自出厂标定,是硬参数,所以输出天然带米制尺度,不需要你再去猜一个系数。
| 路线 | 尺度来源 | 绝对精度 | 主要失效场景 | 上手成本 |
|---|---|---|---|---|
| 单目 + 已知尺寸 | 假设的目标真实尺寸 | 差,强依赖先验 | 遮挡、姿态变化、同类尺寸分散 | 低 |
| 单目深度估计 | 网络学到的相对尺度 | 中等,需后处理对齐 | 换场景尺度漂移、远距离塌陷 | 中 |
| 双目立体匹配(ZED) | 出厂标定的 fx 与基线 B | 近距离好,随距离平方衰减 | 弱纹理、反光、透明、超近距离 | 中 |
ZED 这一路的精度衰减规律要提前有预期:视差误差是固定的亚像素级,而Z对d是反比关系,所以距离越远,同样的视差误差放大出的距离误差越大。经验上 0.5~5 米区间非常好用,5~10 米开始抖,10 米开外基本只能看个大概。你要做的是把业务阈值放在它擅长的区间里,而不是指望它变成激光测距仪。
2.2 硬件与依赖:跑之前先把版本对齐
这套代码要跑起来,硬件侧是三件事:一台 ZED 相机(双目型号都行)、一个能稳定跑满 USB 3.0 带宽的口子、以及一块能跑 CUDA 的显卡。深度图是 SDK 在 GPU 上算的,没有独显会掉到个位数帧率,主循环里再塞一个 YOLO 就更撑不住。USB 线也是常见翻车点,线材质量差会导致相机枚举成功但 grab 频繁超时。
软件侧的核心是pyzed。它不走 pip,是随 SDK 一起装进 Python 环境的,所以先装 SDK,再验证import pyzed.sl as sl能通。其余依赖就是常规三件套:
# pyzed 由 SDK 安装包提供,不要试图 pip install pyzed python -c "import pyzed.sl as sl; print(sl.__version__)" # 先确认 SDK 能被 Python 找到 pip install ultralytics opencv-python numpy这里有个血泪经验:SDK 版本、相机固件版本、pyzed版本三者要大致同级。SDK 太老读不到新固件的相机,会卡在open()返回一个非 SUCCESS 的错误码上,而错误码又不会打印得很直白,很容易误判成「相机坏了」。装完先跑 SDK 自带的示例,示例能出图再跑zedceju.py,能省掉一半排查时间。
YOLO 侧用 ultralytics 的现成权重就够,yolov8n.pt这种小模型在 720p 下单帧推理通常在十毫秒量级,和深度计算能并行跑。模型文件第一次运行会自动下载,离线环境记得提前把权重放到脚本同级目录,否则会卡在下载超时。
2.3 单位与坐标系,不先定死后面全要返工
InitParameters里那个coordinate_units是最容易被忽略、又最容易导致「距离差了 1000 倍」的开关。默认是毫米,而大多数人写业务逻辑时脑子里想的是米。统一成sl.UNIT.METER之后,深度图里每个像素值就是米,YOLO 框上标出来的数字也是米,中间不需要任何换算。如果中途改单位,记得所有阈值常量(比如「小于 1.5 米告警」)一起改,否则就是黑匣子式的 bug。
另一个是深度图和彩色图的对齐。ZED 的深度图默认是对齐到左目图像的,也就是说 YOLO 吃左目图,检测框坐标可以直接拿来索引深度图,不需要做任何仿射变换。这一点非常重要——一旦你手动把右目图或者某个 resize 后的图喂给 YOLO,框坐标就和深度图错位了,症状是「框明明在人身上,读出来的距离却是背景墙面」。所有坐标缩放都必须在同一条尺寸链上完成。
3. 结构拆解:zedceju.py 一条从 grab 到画框的数据流
单文件脚本没有目录结构,但它有隐含的分层。看懂这四层,改起来就知道该动哪一块。
3.1 单文件脚本的隐含分层
按执行顺序,一个典型的zedceju.py长这样:
| 区块 | 职责 | 改动频率 |
|---|---|---|
| 配置区 | 分辨率、帧率、深度模式、置信度阈值、目标类别 | 高 |
| 初始化区 | 打开相机、读取标定参数、加载 YOLO 权重 | 低 |
| 主循环 | grab → 取左目图 → 推理 → 逐框测距 → 画框显示 | 中 |
| 收尾区 | 释放相机、关窗口、打印统计 | 低 |
配置区单独提到最上面是个好习惯。分辨率、置信度、只保留哪些类别,这些参数在不同场地要反复调,散落在主循环里改起来很痛苦。我一般会把它们收成一个字典或者一组常量,主循环里只引用不定义。
3.2 初始化:把相机和模型都拉起来
初始化这段是整个脚本里最容易报错的地方,值得逐行看清楚。
import numpy as np import cv2 import pyzed.sl as sl from ultralytics import YOLO # ---------- 配置 ---------- RESOLUTION = sl.RESOLUTION.HD720 # 720p:帧率与精度比较平衡的一档 FPS = 30 CONF = 0.45 # YOLO 置信度阈值 CLASSES = [0] # 只保留 person,按需扩展 UNIT = sl.UNIT.METER # 全文统一用米 # ---------- 相机初始化 ---------- zed = sl.Camera() init = sl.InitParameters() init.camera_resolution = RESOLUTION init.camera_fps = FPS init.depth_mode = sl.DEPTH_MODE.ULTRA # 精度优先;低配机器可换 PERFORMANCE init.coordinate_units = UNIT init.depth_minimum_distance = 0.3 # 低于 0.3 米直接判无效 init.depth_stabilization = 1 # 深度时域稳定,减少跳变 err = zed.open(init) if err != sl.ERROR_CODE.SUCCESS: # 这里必须停下来,后面所有 grab 都会失败,硬跑只会得到一屏黑图 raise RuntimeError(f"ZED open failed: {err!r}") runtime = sl.RuntimeParameters() runtime.enable_depth = True # ---------- 读标定参数(后面手算校验要用) ---------- cam_info = zed.get_camera_information() calib = cam_info.camera_configuration.calibration_parameters fx = calib.left_cam.fx # 左目焦距,像素单位 baseline = calib.get_camera_baseline() # 基线,与 coordinate_units 同单位 # ---------- 模型 ---------- model = YOLO("yolov8n.pt") names = model.namesdepth_mode是性能与精度的那根旋钮。精度档在弱纹理和边缘处补得更满,代价是 GPU 占用高;性能档帧率好看但边缘容易糊,近距离测距会跳。工位上跑不动就先降到PERFORMANCE,等确认链路通了再往上调。
depth_stabilization打开后,SDK 会对深度做时域滤波,静止目标的读数会明显更稳。代价是目标快速移动时会有轻微拖影,做高速抓取类应用时反而要关掉它。
fx和baseline读出来只做一件事:校验。后面会用它们手算一次视差,跟 SDK 给的深度对一对,确认单位没搞错、坐标没对齐错。
3.3 主循环骨架:五步不能乱序
主循环的顺序是死的,换一下结果就不对。
image = sl.Mat() # 左目彩色图,BGRA depth = sl.Mat() # 深度图,float32,单位由 coordinate_units 决定 while True: # 1) 抓一帧,失败就重试,不要直接 break if zed.grab(runtime) != sl.ERROR_CODE.SUCCESS: continue # 2) 取左目图和深度图(深度已对齐左目) zed.retrieve_image(image, sl.VIEW.LEFT) zed.retrieve_measure(depth, sl.MEASURE.DEPTH) frame_bgra = image.get_data() # H x W x 4, BGRA frame = cv2.cvtColor(frame_bgra, cv2.COLOR_BGRA2BGR) depth_np = depth.get_data() # H x W, float32 # 3) 推理:注意 imgsz 要和输入同尺度,避免内部再缩放导致框坐标偏移 results = model(frame, conf=CONF, classes=CLASSES, verbose=False)[0] # 4) 逐框测距 for box, cls, conf in zip(results.boxes.xyxy.cpu().numpy(), results.boxes.cls.cpu().numpy(), results.boxes.conf.cpu().numpy()): dist = box_distance(depth_np, box) # ... 画框、写距离 # 5) 显示与退出 cv2.imshow("zed+yolo", frame) if cv2.waitKey(1) & 0xFF == 27: break zed.close() cv2.destroyAllWindows()第 3 步有个细节:model(frame)内部会做 letterbox 缩放,ultralytics 返回的boxes.xyxy已经自动映射回原始frame尺寸了,所以框坐标可以直接索引depth_np。但如果你自己手动 resize 过frame再喂进去,就必须自己把框乘回缩放比,否则测距全错。这是新手最常踩的一个坑,症状是「框位置对,距离偏大」。
第 2 步取的是sl.VIEW.LEFT而不是sl.VIEW.IMAGE,因为深度图对齐的是左目。用VIEW.IMAGE在某些型号上拿到的是校正后的合成视图,和深度图存在微小偏移,近距离测距会差几厘米。
4. 核心实现:把 YOLO 的框换算成米制距离
这一节是整个项目的肉。box_distance写得好不好,直接决定读数能不能用。
4.1 框内取哪块像素,才不会被背景带偏
最直觉的做法是取框中心一个点。问题是 YOLO 的框是矩形,人抬起手臂、目标旁边有桌角伸进来,中心点很容易落在背景或者别的物体上。更稳的做法是取框中心的一个内缩矩形区域,然后对这个区域做统计。
内缩比例是个经验值。太小(比如 0.1)等于没内缩,容易被边缘污染;太大(比如 0.6)在远距离小目标上会退化成几个像素,样本量不够,中位数不稳。我用下来 0.3~0.4 比较通用,小目标可以用固定像素下限兜底。
def roi_of_box(box, ratio=0.35, min_px=6): """把检测框内缩成一块中心区域,返回 (x1, y1, x2, y2) 整数像素坐标""" x1, y1, x2, y2 = box w, h = x2 - x1, y2 - y1 # 按比例内缩,但至少保留 min_px 像素,防止小目标缩没了 rw = max(w * ratio, min_px) rh = max(h * ratio, min_px) cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0 return (int(cx - rw), int(cy - rh), int(cx + rw), int(cy + rh))min_px存在的理由是远距离目标。一个 30 米外的人,框可能只有 20×60 像素,乘 0.35 之后宽只剩 7 像素,再被int()一截就变成 0 宽的空区域,patch.size直接为 0。加了这个下限后,再小的框也能取到一块可用区域。
4.2 深度值清洗与中位数聚合
拿到 ROI 之后不能直接np.mean。深度图里有两类脏数据必须过滤:一是无效值,ZED 在弱纹理、反光、透明、超出量程的地方会输出NaN或inf;二是异常值,人物边缘会混进后面的墙,把均值往上拉。
def box_distance(depth_np, box, ratio=0.35, min_m=0.3, max_m=20.0, min_valid=25): """ depth_np : H x W 的 float32 深度图,单位米 box : (x1, y1, x2, y2),像素坐标,与 depth_np 同尺寸 返回 : 目标距离(米);样本不足时返回 None """ h, w = depth_np.shape[:2] x1, y1, x2, y2 = roi_of_box(box, ratio) # 裁到图内,越界会让切片静默返回空数组 x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(w, x2), min(h, y2) if x2 - x1 < 2 or y2 - y1 < 2: return None patch = depth_np[y1:y2, x1:x2].astype(np.float32).ravel() # 1) 去掉 NaN / inf patch = patch[np.isfinite(patch)] # 2) 去掉量程外与相机最小距离内的值 patch = patch[(patch >= min_m) & (patch <= max_m)] # 3) 样本太少不可信,宁可不报 if patch.size < min_valid: return None # 4) 中位数抗边缘污染,比均值稳得多 return float(np.median(patch))几个参数的实际含义要说清楚。min_m除了挡掉噪声,还挡掉了「贴脸」场景——ZED 在小于depth_minimum_distance的范围内基本算不出有效深度,输出的是无效值,直接过滤反而干净。max_m是业务上限,超过 20 米的双目深度已经不可信,留着只会让读数乱跳。
min_valid=25是样本量门槛。这个数是拍脑袋来的,但方向是对的:一个检测框内有效深度点少于二十几个,说明这块区域大概率是弱纹理或者被遮挡,此时报一个数不如不报。业务上可以让「无读数」显示成--,比显示一个错误的0.4m好得多。
用中位数而不是均值,是因为中位数的崩溃点是 50%。框里只要一半以上像素还在目标身上,读数就是目标本身的距离;用均值的话,哪怕 20% 的像素落在远处背景上,结果就被拉偏了。
4.3 用 fx 和 baseline 手算一次,确认 SDK 没骗你
链路刚搭起来的时候,我很建议做一次交叉校验。ZED 的深度是从视差算出来的,那我们就反过来:拿 SDK 给的深度,反推视差,看是不是一个合理的亚像素量级。
def sanity_check(depth_np, fx, baseline, box): z = box_distance(depth_np, box) if z is None: print("该框无有效深度") return disparity = fx * baseline / z # 反推视差,单位像素 print(f"Z={z:.3f} m | fx={fx:.2f} px | B={baseline:.4f} | d={disparity:.2f} px")判断标准很简单:视差应该在零点几到一百多像素之间。如果算出来是几千像素,八成是单位错了(深度是毫米当成米用);如果是 0.00x 像素,八成是baseline和fx取错了相机(比如拿了右目的 fx)。这个方法还有个用处:当你在怀疑「测距不准」到底是算法问题还是硬件问题时,它能快速把范围缩到单位、标定、对齐这三件事上。
顺带提一句,基线 B 的取值单位跟着coordinate_units走。设成米,B 就是 0.12 左右这个量级;设成毫米,B 就是 120。这个数量级对不上,公式立刻给你难看的结果。
4.4 点云接口:除了 Z 还能拿到横向偏移
只报一个距离,做避障是不够的——你还得知道目标偏左还是偏右。ZED 提供了点云测量接口,一次拿到 XYZ 三个分量。
xyz = sl.Mat() zed.retrieve_measure(xyz, sl.MEASURE.XYZ) # 每个像素是 (X, Y, Z),单位同 coordinate_units def box_xyz(xyz_np, box, ratio=0.35): x1, y1, x2, y2 = roi_of_box(box, ratio) patch = xyz_np[y1:y2, x1:x2].reshape(-1, 3) patch = patch[np.isfinite(patch).all(axis=1)] if patch.size == 0: return None # 分轴取中位数:Z 用中位数抗噪,X/Y 同理 return tuple(np.median(patch, axis=0))拿到(X, Y, Z)之后,横向偏角就是atan2(X, Z),做「只对正前方 ±30 度内的目标告警」这类逻辑时非常顺手。要注意点云的 X/Y 精度比 Z 差,因为 X = Z * (u - cx) / fx,Z 的误差会按比例传到 X 上,所以横向角度别用来做精确定位,用来做粗分区足够了。
5. 排查记录:深度图全是 NaN、距离乱跳的五个现场
这一节是从几个实际项目里攒下来的。现象都很有迷惑性,但原因基本落在五个地方。
5.1 现象:所有框都显示--,深度图肉眼可见全黑
原因通常是coordinate_units和过滤区间不匹配。设成毫米时深度值是几百上千,而min_m=0.3 / max_m=20这两刀下去,全部被砍光,patch.size归零,函数一路返回None。
解决办法是先打印一次原始统计再定阈值:
valid = depth_np[np.isfinite(depth_np)] print("min/max/median =", valid.min(), valid.max(), np.median(valid))看中位数的量级就知道当前单位是米还是毫米了。统一改成sl.UNIT.METER之后,这个坑基本只会踩一次。
5.2 现象:人站着不动,距离读数在 1.2 和 3.8 之间反复横跳
原因是 ROI 里混进了背景。人站在墙前,框的左右两侧包含大量墙面像素,中位数的稳定性取决于目标占框内有效像素的比例。如果人只占三成,中位数就会在人和墙之间跳。
解决办法有两步:把ratio从 0.2 提到 0.35~0.45,缩小取样区域;同时打开depth_stabilization,让 SDK 在时域上先滤一遍。如果还跳,就在业务层加一层指数平滑,z_smooth = 0.7 * z_prev + 0.3 * z_now,视觉上立刻老实。平滑系数别设太小,否则目标真的走近了你反应不过来。
5.3 现象:框位置明显偏移,距离读到的是旁边物体的
这个基本可以确定是坐标链断了。三种典型情况:喂给 YOLO 的是 resize 后的图,但框没乘回缩放比;用了sl.VIEW.IMAGE而不是sl.VIEW.LEFT;或者自己做了裁剪、翻转、旋转却没同步变换框。
解决办法是死守一条规则:YOLO 的输入必须是VIEW.LEFT的原始尺寸,除色彩空间转换(BGRA→BGR)之外不做任何几何变换。所有几何处理放在测距之后,作用于「框 + 距离」这个结构体,而不是原始图像。
5.4 现象:zed.open()返回非 SUCCESS,或者 grab 频繁超时
前者多半是版本或权限问题:SDK 与相机固件不同级、USB 口供电不足、Linux 下 udev 规则没装。后者更常见于线材——相机枚举成功但带宽跑不满,跑几秒就超时一次。
排查顺序是:换一个主板直连的 USB 3.0 口(别用前面板、别用 Hub)→ 换一根短一点的优质线 → 跑 SDK 自带示例看是否同样超时 → 降分辨率到 720p 或 VGA 再试。降到 VGA 就稳定,说明是带宽问题,不是相机坏了。
5.5 现象:5 米以内挺准,10 米外数字乱飞
这不是 bug,是双目的物理特性。视差随距离衰减,远距离下亚像素误差被放大,深度置信度骤降。SDK 其实给了置信度图,做远距离业务时应该一起取出来做门限。
conf_map = sl.Mat() zed.retrieve_measure(conf_map, sl.MEASURE.CONFIDENCE) # 0~100 conf_np = conf_map.get_data() # 在 box_distance 里同时要求 patch 对应位置的置信度中位数 > 60工程上的处理是给业务分层:3 米内用精确值做告警,3~8 米只用来看趋势,8 米外直接不显示数值只画框。硬要在 15 米上报两位小数,那个数字除了好看没有任何意义。
6. 二次开发:把测距封装成可复用模块的三步改法
zedceju.py是脚本形态,所有东西都在主循环里,一旦要做多路输出、要接业务回调,就得先拆。我一般按三步改。
第一步,把「相机 + 模型 + 测距」收成一个类,对外只暴露read()方法,返回一个目标列表,每项是{track_id, cls, conf, box, dist, angle}。这样主循环里只剩画图和业务判断,换成别的显示方式(推 RTSP、发 MQTT、存 CSV)都不用动测距部分。
第二步,接上跟踪器,让距离和 ID 绑定。检测框每帧都在变,没有 ID 的话,平滑状态没法维护,告警也没法做「持续 N 秒」的判断。
results = model.track(frame, conf=CONF, classes=CLASSES, persist=True, tracker="bytetrack.yaml", verbose=False)[0] ids = results.boxes.id # 可能为 None(首帧或丢失) targets = [] for i, box in enumerate(results.boxes.xyxy.cpu().numpy()): z = box_distance(depth_np, box) tid = int(ids[i]) if ids is not None else -1 targets.append({"id": tid, "box": box, "dist": z})第三步,给每个 track_id 维护一份状态,做平滑和滞回。平滑用指数滤波,滞回解决「距离在阈值附近抖动导致告警反复触发」——进入阈值用 1.5 米,退出用 1.8 米,中间留 0.3 米死区。
| 改造点 | 改动位置 | 验证方法 |
|---|---|---|
| 封装成类 | 新增ZedRanger,主循环只调read() | 换个输出方式不报错 |
| 接跟踪器 | model.track+boxes.id绑定 | ID 在连续帧中保持稳定 |
| 平滑与滞回 | 按 track_id 维护状态字典 | 人静止时读数波动 < 5 厘米 |
验证方法里我最看重最后一条:找个人站在 2 米处别动,盯十秒,读数波动超过 5 厘米就说明 ROI 或者平滑参数还得调。这个测试比任何指标都直观。
踩过一次坑:我一开始把平滑状态存在了检测索引上,而不是 track_id 上。结果 YOLO 的输出顺序一变,两个目标的距离就串了,画面上的数字像在互相跳。查了半天以为是深度不稳,最后发现是状态绑错了键。从那以后我每次接跟踪,都强制先跑一遍「目标交叉走过」的场景,确认 ID 不串、距离不跳,再往下写业务。
希望帮到你。
本文还有配套的精品资源,点击获取