简介:面向计算机视觉开发者与中高级学习者的 ZED 双目相机结合 YOLO 目标检测测距工程包,围绕立体视觉、目标识别与深度信息融合,解决从环境配置到距离解算的实践需求。压缩包共 42 个文件、约 78.97MB,以 Python 脚本为主体,搭配模型权重、网络配置、XML 工程配置、Markdown 说明及少量 CUDA/C++ 扩展文件,兼顾算法调用、模型加载与工程组织。已有 3016 人学习下载,具备一定参考热度。整套代码展示了 ZED 相机图像与深度数据同 YOLO 检测结果结合后输出目标距离的实现路径,并保留依赖版本处理、相机参数调整与检测模型替换等排错线索;权重与配置文件可直接用于复现实验,目录中的多脚本与模块划分也便于按功能拆解阅读,适合用作双目测距、目标检测部署及立体视觉课程设计的参考样例。
1. 从 ZED 的深度图直接读米:为什么 zedceju.py 比单目估距靠谱
单目估距做法多,用检测框像素高按已知物体高度反推,或假设地面平面、按框底边求交点。物体换型号或姿态稍变,误差翻倍。做双目测距不同:ZED 相机出厂标定基线与焦距,立体匹配算视差,深度由几何公式直接给米数;YOLO 只负责回答“框里是什么”,距离由相机给。资源包主入口是 zedceju.py,打开 ZED、跑 YOLO 检测,把检测框落到深度图上取距离。适合有 ZED 硬件、想把检测结果变成米级距离的从业者,做避障、作业区域测距、目标跟踪前处理,都能从这份代码起步。前提是接受约束:双目深度物理上限明显,近距离准,远距离误差平方增长;接受不了就先别用。
2. 视差到深度:ZED 深度通道取数与 zedceju.py 的最小闭环
2.1 视差、基线与焦距:Z = f·B/d 里三个量在 ZED 上怎么拿
双目测距几何关系只有一行:Z = f · B / d。Z 是相机到物体距离,f 是焦距换算到像素后的值,B 是左右目光心基线,d 是同一物点在左右图上的像素横坐标差。三个量里只有 d 实时变化,f 和 B 出厂标定后视为常数。
难的是量怎么拿、单位是什么。ZED SDK 提供get_camera_information(),取出camera_configuration.calibration_parameters,左目left_cam.fx就是像素焦距,get_camera_baseline()返回基线(单位随coordinate_units走)。不要根据型号猜这些值——同一型号不同批次、不同分辨率下 fx 都不同,分辨率切换后 fx 按比例缩放,基线不变。
单位统一,后面就不会有隐患。我一般把coordinate_units设成sl.UNIT.METER,深度图数值量级才是米,而不是毫米。若测出距离恒为真实值 1000 倍或 1/1000,几乎一定是漏了这一步。
误差从哪来,也值得先算清楚。视差误差 σd 会按平方放大到深度上:
σZ = Z² · σd / (f · B)
以一台基线 120 mm、HD720 下 fx≈700 px 的样机为例,f·B≈84,代入 σd=0.2 px 得到下表。表中最需要注意的是最后两行:5 米外误差跳得很快,对精度要求高的场景必须限制有效量程。
| 真实距离 Z | 理论视差 d | 深度误差 σZ(σd=0.2 px) |
|---|---|---|
| 0.5 m | 168 px | 约 0.6 mm |
| 1.0 m | 84 px | 约 2.4 mm |
| 2.0 m | 42 px | 约 9.5 mm |
| 5.0 m | 16.8 px | 约 6 cm |
| 10 m | 8.4 px | 约 24 cm |
| 20 m | 4.2 px | 约 95 cm |
提示:这张表说明,把
depth_maximum_distance设成 20 米没意义。深度图能给出数值并不代表数值可用;工程上我会按后级业务能容忍的误差反推量程上限,而不是让相机把量程开满。
2.2 深度模式选型:NEURAL / ULTRA / PERFORMANCE 差在哪
ZED SDK 的深度模式本质是几套不同的立体匹配策略。PERFORMANCE追求速度,弱纹理区域容易大面积空洞;QUALITY更耗算力;ULTRA在细结构和边缘上更稳定;NEURAL走神经网络推理,对低纹理、反光、暗光提升明显,代价是必须占用独立显卡,帧率随分辨率下降很快。
选型看三件事:有没有独立显卡、目标距离范围、场景纹理。仓库里码垛、地砖分明,PERFORMANCE够用,还能把 CPU 省下来给 YOLO;白墙、玻璃、夜间车库这类场景,NEURAL减少空洞的效果值回票价。
分辨率与深度模式不能随意组合:分辨率越高,同一模式下帧率和可用量程都会下降。我一般从 HD720 + NEURAL 起步;跑不动时先降深度模式,而不是先降分辨率。分辨率降低后,fx 降低,视差精度下降,测距误差反而更大。这个权衡要看业务落在哪一端。
另有一个容易踩坑的地方:深度图对齐左目、已校正。拿它去索引 YOLO 框时,喂给 YOLO 的图必须是同一张校正后的左目图。若用cv2.VideoCapture从视频设备拉流再对齐,坐标会错位,误差为几像素,在边缘处放大成几十厘米。
2.3 zedceju.py 的最小闭环:打开、取图、取深度、关相机
下面这段是我在 zedceju.py 里抽出的骨架,去掉业务分支后剩下四件事:开相机、抓帧、取深度和左目图、正常关闭。参数逐条说明。
import pyzed.sl as sl import cv2 import numpy as np def open_zed(): zed = sl.Camera() init = sl.InitParameters() init.camera_resolution = sl.RESOLUTION.HD720 # 与 YOLO 输入尺度接近,少一次大比例缩放 init.camera_fps = 30 init.depth_mode = sl.DEPTH_MODE.NEURAL # 无独显时改 PERFORMANCE init.coordinate_units = sl.UNIT.METER # 全局统一米,后面不再除 1000 init.depth_minimum_distance = 0.3 # 太近的数值不可信,直接掐掉 init.depth_maximum_distance = 15.0 err = zed.open(init) if err != sl.ERROR_CODE.SUCCESS: raise RuntimeError(f"ZED open failed: {err}") return zed zed = open_zed() # 打印标定参数,后面算理论误差和核验都要用 info = zed.get_camera_information() calib = info.camera_configuration.calibration_parameters fx = calib.left_cam.fx baseline = calib.get_camera_baseline() print(f"fx={fx:.2f}px, baseline={baseline:.4f}") runtime = sl.RuntimeParameters() runtime.enable_depth = True runtime.enable_point_cloud = False # 只要深度就别开点云,省一次整图拷贝 depth_mat = sl.Mat() left_mat = sl.Mat() while True: if zed.grab(runtime) != sl.ERROR_CODE.SUCCESS: continue # MEASURE.DEPTH 给 float32,单位跟随 coordinate_units zed.retrieve_measure(depth_mat, sl.MEASURE.DEPTH) zed.retrieve_image(left_mat, sl.VIEW.LEFT) # BGRA 四通道 frame = cv2.cvtColor(left_mat.get_data(), cv2.COLOR_BGRA2BGR) depth = depth_mat.get_data() # H x W,float32,无效值为 0 或 inf cv2.imshow("left", frame) if cv2.waitKey(1) == 27: break zed.close() # 不关会在下次 open 时拿到设备占用错误四段逻辑:open_zed()负责一次性初始化,RuntimeParameters控制每帧能力,抓帧成功后分别取深度和左目彩色图,最后显式close()。三个参数最影响结果——coordinate_units决定单位,depth_mode决定空洞多少,depth_minimum_distance和depth_maximum_distance决定哪些像素直接判为无效。retrieve_measure返回的是共用的矩阵,不要在循环里反复重新读取整张图再逐帧复制;否则帧率会下降一半,很多人第一版就踩这个坑。
3. YOLO 框落到深度图上:三种取距策略与一次时序滤波
3.1 为什么框的中心点不等于物体的距离
框中心对应的像素,可能是物体表面,也可能是物体和背景之间的过渡区域。物体和背景距离差得远时,这两种情况取到的距离能差出一米。框边缘更糟:立体匹配在遮挡边界容易产生“飞点”,深度图上表现为物体轮廓外侧一圈数值异常。用框内所有像素直接求均值,被这圈飞点一拉,结果明显偏远。
所以取距的关键,不是框画得准不准,而是有没有在框内挑一个“干净”的子区域,并用稳健统计量代替均值。中位数比均值抗飞点,分位数截断再把极端值削一遍,这两个动作叠加,抖动会明显小一个量级。
还有一个概念要先摆正:深度图是左目校正图上的稠密场,YOLO 输出框坐标也落在同一张图坐标系里。中间只要有一次cv2.resize、裁剪或翻转没有同步到深度图上,坐标就不再一一对应。这种情况表现最隐蔽:结果看起来“有数”,但系统性偏移几厘米,且怎么调参都调不回来。
3.2 三种取距策略:中心采样、ROI 中位数、分位数截断
三种策略我会都实现,让业务按场景切换。中心采样最快,适合小目标和实时性极限场景;ROI 中位数适合常规目标;分位数截断适合有遮挡和多目标粘连的场景。
import numpy as np def center_distance(depth_map, box): """策略一:只取框中心一个小十字窗口的中位数,最快但最脆""" h, w = depth_map.shape[:2] x1, y1, x2, y2 = [int(v) for v in box] cx, cy = (x1 + x2) // 2, (y1 + y2) // 2 patch = depth_map[max(0, cy - 2):min(h, cy + 3), max(0, cx - 2):min(w, cx + 3)] vals = patch[np.isfinite(patch)] vals = vals[vals > 0] return float(np.median(vals)) if vals.size >= 5 else None def roi_distance(depth_map, box, inner=0.5, min_valid=30, lo=10, hi=90): """策略二/三:框中心 inner 比例区域 + 分位数截断 + 中位数 inner: 收缩比例,0.5 表示只保留框中心一半宽高的区域 min_valid: 有效像素下限,不够就判定本帧无效 lo/hi: 分位数截断区间,削掉飞点和前景残留 """ h, w = depth_map.shape[:2] x1, y1, x2, y2 = box cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0 hw = (x2 - x1) * inner / 2.0 hh = (y2 - y1) * inner / 2.0 xa, xb = max(0, int(cx - hw)), min(w, int(cx + hw)) ya, yb = max(0, int(cy - hh)), min(h, int(cy + hh)) patch = depth_map[ya:yb, xa:xb] if patch.size == 0: return None, 0 vals = patch[np.isfinite(patch)] vals = vals[vals > 0] # 0 与 inf 都是无效值,必须剔 if vals.size < min_valid: return None, int(vals.size) p_lo, p_hi = np.percentile(vals, [lo, hi]) keep = vals[(vals >= p_lo) & (vals <= p_hi)] return float(np.median(keep)), int(vals.size)inner是最值得调的参数。目标在画面里占得大、背景穿透少,可以给到 0.6 到 0.7;小目标给 0.5,再小就别收缩,否则有效像素不够。min_valid是有效性检查:小于 30 个有效点直接返回 None,让上层逻辑知道“这一帧不可信”,比返回错误数值安全。lo/hi取 10/90 是我常用的起点,飞点多时收到 25/75,目标表面本身起伏大(比如斜放的箱子)就放宽到 5/95。
MEASURE.CONFIDENCE可以打开,得到 0 到 100 的置信度图,跟深度图同尺寸。把置信度低于阈值(比如 60)的像素一并剔掉,弱纹理区域的飞点会少很多。代价是有效像素变少,min_valid要相应下调。这属于参数组合,没有通用值,只能在场景里试。
3.3 加一层时序:滑动窗中位数与简单 IoU 关联
单帧深度抖动是双目的常态,尤其在距离边缘附近。物体静止、相机静止,读数仍上下跳几厘米。直接用这个数触发业务逻辑,告警会频繁误触发。
最省事的修法是滑动窗中位数。窗口取 7 到 9 帧:太短压不住抖动,太长引入滞后,运动目标距离变化跟不上。
from collections import deque class TrackDistance: """按轨迹维护一个距离滑窗,输出窗口内中位数""" def __init__(self, n=9, max_jump=0.5): self.buf = deque(maxlen=n) self.last = None self.max_jump = max_jump # 单帧跳变上限,超过判为异常 def update(self, z): if z is None or not np.isfinite(z) or z <= 0: return self.last # 本帧无效就沿用上次,保持输出连续 if self.last is not None and abs(z - self.last) > self.max_jump: return self.last # 突跳大概率是飞点,先挡一次 self.buf.append(z) self.last = float(np.median(self.buf)) return self.last窗口配合轨迹 ID 才有意义。项目里最简单做法是贪心 IoU 匹配:上一帧每个框和本帧所有框算 IoU,取最大且超过 0.3 的配对,配不上就新建轨迹,连续 N 帧没配上就销毁。这套写法不用引入额外依赖,几十行足够,比直接套现成跟踪器更好排查。
这里补充一个容易忽略的细节:框中心跳变会让inner子区域整体位移,中位数跟着跳。目标缓慢移动时,最好用轨迹的历史框做平滑,或者直接对“距离序列”做中值滤波,而不是只对单帧深度做处理。两层滤波叠加,输出曲线会平滑很多。
4. 参数怎么配:分辨率、深度模式与精度核验的三组实测
4.1 分辨率与帧率:把算力花在刀刃上的对照表
分辨率的取舍要同时考虑三件事:fx 变大后视差精度提升、匹配耗时上升、YOLO 预处理开销上升。经验上 HD720 是性价比拐点,再往上收益递减明显。
| 分辨率 | 典型用途 | 深度可用量程 | 说明 |
|---|---|---|---|
| VGA | 高帧率预览、粗略避障 | 短 | fx 小,远处视差不足 1 px,只能做近场 |
| HD720 | 多数检测+测距场景 | 中 | 算力与精度的平衡点,优先从这里起步 |
| HD1080 | 中远距离、需要更多细节 | 中远 | 匹配耗时明显上升,注意帧率 |
| HD2K | 静态扫描、离线处理 | 远 | 实时链路基本跑不动,别用在在线检测上 |
帧率低不一定是相机本身的问题。常见组合问题:HD1080 以上分辨率 + NEURAL + YOLO 每帧推理,三件事同时进行。工程上的做法是 YOLO 隔帧推理,中间帧靠轨迹延续,深度照常每帧取。检测框位置在两帧之间变化很小,隔帧对结果几乎没影响,但帧率能翻倍。
camera_fps不要直接设成 60 或 100。相机标称支持并不代表当前分辨率与深度模式下能跑满;设得过高,grab()会返回失败或阻塞,表现为“程序卡住但没报错”。我一般保持 30,稳定后再试着往上抬,抬完观察连续 10 秒的成功抓帧率。
4.2 YOLO 输入尺寸与 ZED 出图的对齐细节
用 ultralytics 的接口时,model.predict(frame, imgsz=640)内部做了 letterbox 缩放,返回的boxes.xyxy已经映射回frame原始像素坐标。也就是说,只要传进去的是 ZED 左目图,框坐标就能直接索引深度图,不需要手动缩放。
from ultralytics import YOLO model = YOLO("yolov8n.pt") def detect(frame, conf=0.35, iou=0.5): res = model.predict(frame, imgsz=640, conf=conf, iou=iou, verbose=False, device=0)[0] boxes = res.boxes.xyxy.cpu().numpy() clses = res.boxes.cls.cpu().numpy().astype(int) # 确认没有外部缩放:res.orig_shape 应与 frame.shape 前两位一致 assert tuple(res.orig_shape) == frame.shape[:2], "坐标被缩放过" return boxes, clses那个assert看着多余,实际救过我好几次。有人为了省显存,在调用前先cv2.resize到 640×360,再把框乘回原尺寸;一旦缩放比例取整出错,偏差就固定几像素,测距结果整体偏一两厘米,怎么调深度参数都改不回来。让框架自己做 letterbox,返回原图坐标,是最省心的做法。
conf和iou这两个阈值,测距场景下的取值跟纯检测不一样。距离误差对框的稳定性非常敏感,conf我会从 0.35 起,宁缺毋滥;iou保持 0.5,多目标粘连时调到 0.45 减少合并。被漏检的目标由轨迹延续补,比低阈值引入大量抖动框要划算。
4.3 精度核验:三米卷尺做三点标定
代码写完不代表结果正确。核验方法很土但有效:拿一块平整硬板,分别放到 1 米、3 米、5 米三个位置,每个位置用卷尺量出实际距离(以板面中心到左目镜头前表面为准),然后在板上贴一个高对比标记,用 YOLO 框住或直接框选,读roi_distance的输出。
| 实际距离 | 读数值 | 偏差 | 判断 |
|---|---|---|---|
| 1.0 m | 记录 | 记录 | 偏差应小于 2 cm |
| 3.0 m | 记录 | 记录 | 偏差应小于 6 cm |
| 5.0 m | 记录 | 记录 | 偏差应小于 15 cm |
三个位置都偏同一个方向、数值接近,基本是最小距离参数或单位问题;偏差随距离快速放大,是正常的平方规律,不要试图靠调参消除;只有中间某个位置偏差特别大,多半是那一档距离上匹配失败,去看深度图在板上是不是一片空洞。
核验时有个细节:量的是左目镜头前表面到板面的距离,而深度图中的 Z 是相对左目光心。两者差一个镜头前端到光心的几毫米到一厘米,做毫米级核验时要扣掉。工程上精度要求到厘米级就可以忽略,但心里要知道这个偏置存在。
5. 避坑与排查:跑不起来、数值乱跳的五个现场
5.1 环境与设备类现场
现象:zedceju.py 一启动就抛相机打开失败,错误码提示设备未检测到。原因通常是两类:一是 USB 链路不稳,接到了 USB 2.0 口或经过无源扩展坞,带宽不够,初始化直接失败;二是设备被占用,桌面端的相机查看工具没关,SDK 拿不到独占权限。 解决顺序是先关掉所有可能占用设备的进程,再换机箱后面的原生 USB 3 口和原装线重试。仍然失败,就看系统识别设备的日志,确认相机是否已经枚举为 USB 3 设备。这个环节我吃过一次亏,换了三根线才发现是扩展坞的问题。
现象:把depth_mode设成NEURAL后,初始化报错或自动退回低一级模式。原因是没有可用的独立显卡、显存不足,或者驱动与 SDK 版本不匹配。神经网络深度模式依赖 GPU 推理,集显环境基本起不来。 解决是把深度模式降级到PERFORMANCE或QUALITY,先让链路跑通,再评估要不要为 NEURAL 换机器。降级后空洞变多,需要同步调高min_valid,并把置信度过滤打开,否则飞点会明显增多。
现象:分辨率调到 HD1080 以上后,帧率掉到个位数,测距延迟肉眼可见。原因是高分辨率下立体匹配耗时随像素数上升,叠加 YOLO 每帧推理,两件事抢同一块算力。 解决分两步:先把分辨率退回 HD720 验证瓶颈在哪;确认是高分辨率导致后,改为 YOLO 隔帧推理 + 轨迹延续,深度照常每帧取。若业务确实需要高分辨率的远距离精度,就把检测频率降下来,把算力预算明确分给匹配。
5.2 数据与算法类现场
现象:深度图大片为 0 或 inf,目标框里有效像素不足,roi_distance一直返回 None。原因有三个常见来源:目标距离超出depth_minimum_distance与depth_maximum_distance设定区间;目标表面是玻璃、镜面、纯白墙这类弱纹理或强反光材质,立体匹配找不到对应点;环境照度过低,纹理不可辨。 解决是先打印框内有效像素数与总像素数的比值,比值低于 20% 就查距离区间设置;区间没问题再换材质或补光验证。工程上的兜底是允许返回 None,让上层逻辑明确知道“此刻没有可信距离”,而不是拿一个错误数值继续跑。
现象:物体静止不动,输出距离却在上下跳,幅度从几厘米到半米不等。原因通常是三个问题叠加:只取了单像素或极小的中心窗口,没有做区域统计;框边缘混入了背景像素和飞点;完全没有时序滤波。 解决按顺序来:先把策略从中心采样换成 ROI 中位数加分位数截断,观察抖动幅度有没有降一个量级;再打开置信度图过滤低置信像素;最后加上滑动窗中位数和跳变门限。三步做完还是抖,就要回头看是不是目标本身在弱纹理区域,匹配质量先天不足,这时候换深度模式比继续调参有用。
6. 把距离接进业务前,先做一次回放验证
在线调试最大的问题是不可复现。同一个场景跑两遍,抖动位置都不一样,改一个参数后也说不清是参数起作用还是场景变了。我现在的习惯是先把场景录下来,再离线反复跑。
ZED SDK 支持录制 SVO,把左右目原始数据、时间戳、传感器信息一起存进单文件,回放时和在线几乎一样,只是不用重新采数据。
import pyzed.sl as sl # 录制:开相机时挂上录像文件 init = sl.InitParameters() init.camera_resolution = sl.RESOLUTION.HD720 init.depth_mode = sl.DEPTH_MODE.NEURAL init.coordinate_units = sl.UNIT.METER zed = sl.Camera() zed.open(init) zed.enable_recording("clip.svo", sl.SVO_COMPRESSION_MODE.H264) # 换成 LOSSLESS 更保真但更大 # 回放:把输入源换成文件,其余流程一行不用改 init2 = sl.InitParameters() init2.input.setFromSVOFile("clip.svo") init2.svo_real_time_mode = False # 关掉实时模式,能跑多快跑多快 init2.coordinate_units = sl.UNIT.METER zed2 = sl.Camera() zed2.open(init2)回放验证有个好处:同一段数据可以跑不同参数组合,做 A/B 对照。我一般固定三个指标来评估一次参数改动——有效帧率(能给出可信距离的帧占比)、距离序列的标准差、以及和卷尺真值的平均绝对偏差。三个指标一起看,避免出现“抖动小了但有效帧掉一半”这种看似变好、实际更差的情况。
还有一点值得单独强调:svo_real_time_mode = False时回放速度远快于实时,一小时的录像几分钟跑完,非常适合回归测试。改完代码先拿三五个固定片段过一遍,指标没退步再上真机,能省掉大量在场地里反复复现的时间。
最后说个我自己的教训。早期我总想着把参数一次调到位,结果每次改动都伴随一堆变量同时变,出了问题根本定位不到是哪一处。后来强制自己走一遍流程:先固定分辨率与深度模式,只调取距策略;策略定了再调时序窗口;最后才动 YOLO 的置信度阈值。每次只动一个,回放验证,记下三个指标。慢是慢了一点,但再没出现过“改完好像好了、过两天又不行”的反复。希望帮到你。
本文还有配套的精品资源,点击获取