
简介围绕ZED双目立体相机与YOLO目标检测框架实现距离测量的工程代码包面向已具备Python与OpenCV基础、希望在机器人导航、自动驾驶或三维感知场景中落地“检测测距”能力的开发者与高年级学生重点应对环境配置、模型加载与深度信息对齐等实操难点。压缩包共42个文件、约78.97MB以20个Python脚本为主体涵盖主运行脚本、相机封装、检测推理与结果可视化模块另附xml与iml等IDE工程配置、由cfg与weights构成的YOLOv4及YOLOv4-tiny模型文件、md说明文档以及Makefile与CUDA插件源码兼顾纯Python推理与TensorRT加速两条路径。已有3016人学习下载。读者可获得可复现的模块划分与清晰目录结构参考标定流程、检测结果绘制与距离计算逻辑并快速定位依赖版本冲突适合作为课程设计、毕业设计或项目原型的起步骨架。1. 从 zedceju.py 的一次误判说起ZED 双目测距配 YOLO 到底在算什么第一次把相机插上跑 zedceju.py检测框的位置挺准框旁边那个距离数字却像在乱猜——人站在三米开外屏幕写着 0.31 米。ZED 双目测距加 YOLO 这套组合是把两件事缝在一起YOLO 负责在左目图像里找出目标框ZED 的深度引擎负责把左右目视差换算成每个像素的米数脚本再把框内那一片像素的深度收成一个数。它适合手上已经有一台双目相机、想让画面里的目标带上距离标签的人比如移动底盘的近距避障、料框和工件的粗测距、单目估距精度不够时的补强。难点在于检测和测距各有一套自己的坐标系、单位和有效范围任何一处对不上数字就会离谱到让人怀疑硬件坏了。2. 环境落地ZED SDK、pyzed 与 YOLO 依赖的装法装环境这一步看着简单实际是整条链路里最容易耗掉半天的地方。原因很实在ZED 的 Python 绑定不是一条 pip 命令就能搞定它跟 SDK 本体、显卡驱动、USB 带宽绑在一起而 YOLO 那一侧又是纯 Python 生态两套东西装在同一台机器上冲突点主要集中在 Python 版本和 OpenCV 版本上。我一般先把相机在 SDK 自带的诊断工具里跑通确认能出深度图再去碰检测代码顺序反了排查成本会翻倍。2.1 硬件和驱动先过一遍别在供电上翻车双目相机对带宽很敏感。HD720 分辨率 30 帧的双目图像流加上深度计算跑在 USB 3.0 上才算稳插在 USB 2.0 口或者经过一个不带独立供电的扩展坞典型表现是能打开、能出图但跑几十秒后 grab 开始返回失败或者帧率掉到个位数。开机前把这几件事确认掉线材用相机原配那根很多第三方 Type-C 线只跑 USB 2.0 的数据对。直插主板后置接口别走前面板延长线。关掉 SDK 自带的示例程序和相机预览工具它们会独占设备句柄。Linux 下确认已按 SDK 安装包里的说明装好 udev 规则否则普通用户权限打不开设备。诊断命令跑一次能列出设备序列号、USB 速率、固件版本基本就说明硬件侧没问题了。# 列出连接的相机及其 USB 速率、固件版本 ZED_Diagnostic -c # 输出一段带深度的示例验证深度引擎是否正常 ZED_Diagnostic --depth-c只做静态检查不打开图像流用来确认设备被识别--depth会真正启动深度计算如果这个能跑出深度图说明 SDK、驱动、CUDA 这一路都通了后面出问题就只可能出在 Python 侧。2.2 Python 侧依赖pyzed、ultralytics、opencv 的版本搭配pyzed这个包在 PyPI 上并不提供通用 wheel正确姿势是用 SDK 安装目录里附带的脚本它会根据本机 Python 版本和 CPU 架构现场编译出一个匹配的 wheel。所以第一步是先确定用哪个 Python 解释器最好是一个干净的虚拟环境装完之后不要随便升级 Python。# SDK 默认装在 /usr/local/zedWindows 下在安装目录的 python 子目录 cd /usr/local/zed # 按当前 python3 版本生成并安装 pyzed 的 wheel python3 get_python_api.py # 检测侧的依赖版本不要追新锁一个能跑的组合 pip install ultralytics8.2.* opencv-python4.9.* numpyget_python_api.py做的事就是读本机 Python 的 ABI 版本从 SDK 里挑对应的预编译库拷出来打包所以如果你在 venv 里操作一定要先激活 venv 再执行否则包装到系统 Python 里venv 里 import 会报找不到模块。ultralytics 和 opencv 版本锁得保守一点是有必要的OpenCV 4.10 之后部分发行版改了图像编解码依赖和 pyzed 自带的那个 OpenCV 动态库偶尔会在同一进程里打架表现为 import 顺序一变就段错误。真撞上了把 pyzed 的 import 放在最前面通常能绕过。装完立刻验一次别等到跑主脚本才发现问题import pyzed.sl as sl print(sl.__version__) # 能打印出版本号说明绑定装对了 print(sl.Camera().is_opened()) # 未 open 时应为 False不报错即正常2.3 第一次启动 zedceju.py从命令行参数到首帧输出主脚本的启动流程一般是这样解析参数、打开相机、加载权重、进主循环。第一次跑建议把分辨率压到 HD720、检测模型换成最轻的那一档先把链路跑通再谈精度。# 最保守的一次启动轻量模型 中等置信度 性能优先的深度模式 python zedceju.py \ --weights yolov8n.pt \ --conf 0.4 \ --res HD720 \ --depth-mode PERFORMANCE参数含义和你需要动的部分参数作用调它的时机--weights检测权重路径首次运行会自动下载换成自己训练的模型时改这里--conf检测置信度阈值漏检多就降到 0.25误检多就升到 0.5--res相机输出分辨率远距离小目标用 HD1080追求帧率用 HD720--depth-mode深度计算模式调试用 PERFORMANCE出成品再换 NEURAL启动后应该看到一个带检测框和距离数值的窗口。如果窗口能出来但数字全是 0 或者 nan先别改代码去看 2.1 里那条深度诊断命令的结果八成是深度引擎没正常工作。首帧能稳定出数再往下读代码拆解才有意义。3. 代码骨架拆解zedceju.py 从取流到出距离的完整链路zedceju.py 这类脚本的结构其实很固定五段式开相机、取帧、推理、算距离、画图上屏。代码量通常两三百行真正决定结果对不对的是中间三段里几个不起眼的参数。下面按执行顺序拆开讲每段给一段可以直接抄的骨架你拿自己的脚本对一遍就知道差在哪。3.1 相机初始化与 depth_mode 选择初始化做了三件事定分辨率帧率、选深度模式、定单位。这三项里最容易出事的是单位——SDK 默认用毫米而检测框的坐标是像素深度图取出来如果是毫米你按米去理解就会差 1000 倍正好对上第 1 章那个三米读成 0.31的现象。import pyzed.sl as sl def open_camera(ressl.RESOLUTION.HD720, fps30, unitsl.UNIT.METER): zed sl.Camera() p sl.InitParameters() p.camera_resolution res # 图像流分辨率影响检测小目标的能力 p.camera_fps fps # 帧率上限实际受 USB 带宽和算力约束 p.depth_mode sl.DEPTH_MODE.NEURAL # 神经网络深度近距细节好吃 GPU p.coordinate_units unit # 关键米制深度图直接是米 p.depth_minimum_distance 0.3 # 近端裁剪太近的视差解不出来 p.depth_maximum_distance 20.0 # 远端裁剪超过这个距离误差已经很大 err zed.open(p) if err ! sl.ERROR_CODE.SUCCESS: raise RuntimeError(f相机打开失败: {err}) return zeddepth_mode有四个常用档位PERFORMANCE最快但边缘毛糙QUALITY均衡ULTRA精度最高但掉帧明显NEURAL靠模型补全弱纹理区域在墙面、地面这类大面积无纹理场景下比传统块匹配稳得多代价是要占显存。做产品先按场景选室内近距、目标表面平整NEURAL值得室外强光、远距离ULTRA反而更容易出噪点QUALITY更省心。depth_minimum_distance和depth_maximum_distance不是摆设。设得太宽深度图里会混进大量无效值你后面在框内取中位数时会被这些 0 和 nan 拖偏设得太窄目标一走出范围就直接没数。现场调试时我一般先按实际工况把范围卡紧宁可测不到也不要测错。3.2 取帧、通道转换与 YOLO 推理取帧这段有个必须记住的坑ZED 的retrieve_image返回的是 BGRA 四通道直接喂给 ultralytics 会当成四通道图处理轻则报错重则检测结果偏色框乱飞。import cv2 import numpy as np from ultralytics import YOLO runtime sl.RuntimeParameters() runtime.confidence_threshold 60 # 深度置信度阈值低于它的像素置为无效 runtime.enable_fill_mode True # 对无效区域做邻域填充减少空洞 image sl.Mat() depth sl.Mat() model YOLO(yolov8n.pt) def grab_pair(zed): if zed.grab(runtime) ! sl.ERROR_CODE.SUCCESS: return None, None # 左目图像检测用右目一般不需要除非你要做立体匹配对比 zed.retrieve_image(image, sl.VIEW.LEFT) # 深度图32 位浮点单位跟随 coordinate_units zed.retrieve_measure(depth, sl.MEASURE.DEPTH) frame cv2.cvtColor(image.get_data(), cv2.COLOR_BGRA2BGR) return frame, depth.get_data() def detect(model, frame, conf0.4): # imgsz 固定住避免每帧自适应缩放带来的耗时抖动 r model.predict(frame, confconf, imgsz640, verboseFalse)[0] return r.boxes.xyxy.cpu().numpy(), r.boxes.cls.cpu().numpy()runtime.confidence_threshold控制的是深度估计本身的置信度不是检测置信度两个阈值别混。把它调到 60 左右能滤掉大部分由反光、重复纹理产生的错误深度。enable_fill_mode打开后物体边缘那圈原本是无效值的像素会被邻域填上好处是框边缘不会整片没数坏处是填充值本身精度一般所以后面取深度时要往框内缩避开边缘。imgsz640固定下来是为了让推理耗时稳定。ultralytics 默认会做 letterbox 缩放输出的框坐标已经映射回原图所以你不需要自己做任何缩放还原——这一点很多人会多此一举地再乘一遍比例结果框全部偏移。3.3 框内深度聚合从深度图到一个米数这一步决定了最终数字准不准。直接把框中心那一个像素的深度拿出来用是最省事的做法也是最不稳的做法单点很容易踩在空洞或者误匹配上。def box_distance(depth_map, box, shrink0.2, min_valid50): depth_map: HxW float32单位米无效值为 0 或 nan box: [x1, y1, x2, y2]原图像素坐标 shrink: 框内缩比例避开边缘填充值 min_valid: 有效像素少于这个数就认为本帧不可信 h, w depth_map.shape[:2] x1, y1, x2, y2 map(int, box) x1, y1 max(0, x1), max(0, y1) x2, y2 min(w, x2), min(h, y2) if x2 x1 or y2 y1: return None # 按比例往框内收边缘像素多半是填充值或背景混入 dx int((x2 - x1) * shrink) dy int((y2 - y1) * shrink) roi depth_map[y1 dy:y2 - dy, x1 dx:x2 - dx] if roi.size 0: return None vals roi[np.isfinite(roi) (roi 0)] if vals.size min_valid: return None return float(np.median(vals))shrink0.2的意思是四边各收掉框宽高的 20%留下中间 60% 的区域。这个比例对行人、箱子这类矩形目标合适细长目标比如一根杆子收 20% 可能把整个目标收没了那就改成只收上下不收左右。min_valid是可信度闸门有效像素太少说明这一帧的深度基本是噪点宁可不报数也别报错数——下游如果是避障逻辑一个错的近距值比没有值危险得多。取中位数而不是均值是因为深度图里的错误值几乎总是偏小误匹配到更近的纹理均值会被这些小值往下拽中位数对这种单侧污染更稳。3.4 绘制与退出别让相机句柄泄漏画图这部分没什么技术含量但退出逻辑是实打实会咬人的地方。相机句柄不释放下次启动直接报设备被占用重启机器才能恢复。try: while True: frame, depth_map grab_pair(zed) if frame is None: continue boxes, clss detect(model, frame, conf0.4) for box, cid in zip(boxes, clss): d box_distance(depth_map, box) x1, y1, x2, y2 map(int, box) label f{model.names[int(cid)]} {d:.2f}m if d else f{model.names[int(cid)]} -- cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 200, 0), 2) cv2.putText(frame, label, (x1, y1 - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 200, 0), 2) cv2.imshow(zedyolo, frame) if cv2.waitKey(1) 0xFF ord(q): break finally: zed.close() # 放在 finally 里异常退出也能释放 cv2.destroyAllWindows()zed.close()一定要写在finally里。调试阶段脚本动不动就被 CtrlC 打断写在正常流程末尾的话十次里有八次是没执行到的第二天开机第一件事就是发现相机打不开。4. 精度调优坐标系、单位与深度取值策略链路跑通以后屏幕上会有一堆数字但数字准不准是另一回事。双目测距的理论底子很简单Z f × B / dZ 是距离f 是焦距像素B 是基线长度米d 是视差像素。这个式子说明两件事——距离和视差成反比所以远距离时视差每差一个像素距离误差会被放大得厉害而 f 和 B 是相机出厂标定好的常数你能动的只有 d 的估计质量。ZED 已经把标定和视差匹配封装进 SDK 了剩下的优化空间集中在单位、坐标系和取值策略上。4.1 单位和坐标系错一个数量级就废了单位这件事前面提过还是要单独拎出来说因为它是最高频的翻车点。判断方法很简单让一个已知身高的人站在卷尺量好的 2 米处看读数。读数在 2000 上下就是毫米在 2.0 上下就是米在 0.3 上下那就是别的问题了。三个相关配置项要对齐配置常见取值取错的典型表现coordinate_unitssl.UNIT.METER读数和真实距离差 1000 倍coordinate_systemRIGHT_HANDED_Y_UP点云上下颠倒Z 轴方向反了depth_modeNEURAL/QUALITY弱纹理区域大片空洞读数频繁为 None坐标系主要影响你要拿点云做三维定位的场景。如果只是画面上标个距离坐标系可以不管一旦你要从MEASURE.XYZ里取目标的真实三维坐标去驱动机械臂或者做地图构建坐标系反了就是灾难性的——Z 轴朝下的时候地面上所有目标的高度都会变成负数。# 只在需要三维坐标时才取点云它有额外拷贝开销 point_cloud sl.Mat() zed.retrieve_measure(point_cloud, sl.MEASURE.XYZ) cx, cy (x1 x2) // 2, (y1 y2) // 2 err, pt point_cloud.get_value(cx, cy) if err sl.ERROR_CODE.SUCCESS: x, y, z pt[0], pt[1], pt[2] # 单位跟随 coordinate_unitsget_value返回的err必须判无效像素会返回一个非 SUCCESS 的状态并且 pt 里是 nan不判就会把 nan 一路带到下游。4.2 框内取值均值、中位数还是分位数取值策略直接决定读数的稳定性。我拿同一段室内素材做过对比目标是一个 3 米外的纸箱连续 300 帧策略均值偏差帧间抖动标准差适用场景框中心单点大极大只适合快速验证链路框内均值偏近中目标表面平整、深度干净框内中位数小小通用默认推荐框内 30% 分位数偏近小关心最近表面如避障均值偏近的原因是深度错误值单侧偏小这条前面说过。30% 分位数适合避障场景——你关心的是目标离你最近的表面而不是它的几何中心比如一个人侧身站立中位数给的是身体中轴的距离分位数给的是他肩膀到你的距离后者对避障更有意义。策略定了还要定窗口。单帧读数必然抖工程上加一个长度为 5 到 10 的滑动窗口做中值滤波输出就稳了。窗口别开太大超过 10 帧会让读数对真实运动的响应变迟钝目标走近了你还在报旧值。4.3 误差来源与现场标定的小技巧双目测距的误差不是均匀分布的它随距离平方增长这是公式决定的软件层面救不回来。除了距离还有几个具体来源值得记一下弱纹理表面白墙、纯色纸箱、抛光金属左右目匹配找不到特征点深度大面积失效。解决思路是打光或者贴标记点软件层面只能靠NEURAL模式补。重复纹理栅栏、地砖、货架匹配会错位一个周期读出来的距离偏差是离散的跳变很典型。反光与透明玻璃、水面直接无解物理层面的事。目标边缘前景和背景混在一个框里中位数会被拉向像素更多的那一侧。现场标定我一般这么干拿卷尺在地面上标 1、2、3、5、8 米五个点每个点放一个带纹理的板子各读 100 帧取中位数把真实值和读数做成一张对照表。如果发现系统性偏移比如全程都偏近 3%那多半是基线或者焦距的标定参数和实际有出入可以在脚本里补一个线性修正系数比重新标定省事得多。# 最简单的线性修正用两个标定点拟合 scale 和 offset def calibrate(raw_d, scale1.0, offset0.0): return raw_d * scale offset修正系数别硬编码在业务逻辑里写成配置文件读进来换一台相机就换一份参数。这个习惯救过我很多次——同一批次的相机标定参数也会有零点几个百分点的差异。5. 踩坑排查ZED 与 YOLO 联调现场最常见的 5 个问题5.1 现象深度值全是 0 或者 nan检测框正常原因通常是深度引擎没真正启动最常见的是depth_mode设成了NONE或者 SDK 打开相机时深度模块初始化失败但open()依然返回成功。另一种可能是权限或者显存不足NEURAL模式在显存吃紧的机器上会静默降级。解决先用ZED_Diagnostic --depth确认硬件侧能出深度代码里把open()之后的状态打印出来包括zed.get_camera_information().camera_configuration显存不够就把模式降到QUALITY或PERFORMANCE别硬撑。5.2 现象读数稳定地差 1000 倍原因就一个单位没对齐。coordinate_units默认是毫米而你在代码注释里按米理解。解决初始化时显式写p.coordinate_units sl.UNIT.METER并且在box_distance里加一条断言读数值大于 100 的时候打印一次警告让问题在调试期就暴露出来。5.3 现象检测框整体偏移或者框的大小只有实际的一半原因是自己做了多余的坐标缩放。ultralytics 输出的框坐标已经是相对原图的很多人在model.predict之前手动 resize 了图像然后又按缩放比例把框乘回去等于缩放做了两次。解决喂给模型的图和画框的图用同一张别自己 resize需要控制输入尺寸就用imgsz参数交给 ultralytics 内部的 letterbox 处理。5.4 现象跑几分钟后 grab 开始连续失败帧率断崖下跌原因是 USB 带宽或者散热。双目 1080p 加深度计算对带宽和 GPU 的压力都不小笔记本上尤其容易撞到温度墙。解决先降到 HD720 试能稳住就说明是带宽或算力问题加个失败计数器连续失败超过阈值就zed.close()然后重新open()做一次软重连比直接崩溃体面。5.5 现象同一目标在不同帧的读数跳变一两米原因是框内混进了背景深度。目标贴近墙面或者站在地面上时框的下半部分和边缘大量是背景像素中位数被拉走。解决加大shrink到 0.3或者只取框的上半部分对人形目标有效同时开滑动窗口中值滤波。如果目标本来就近把depth_maximum_distance卡到工况上限让远处的背景像素直接变成无效值。6. 二次开发把测距结果接进自己的业务链路脚本能跑之后下一步基本都是把它塞进自己的系统。我的做法是先封成一个类把相机、模型、窗口这些东西藏在里面对外只暴露一个read()返回结构化的结果列表这样上层业务逻辑完全不用关心 ZED 和 YOLO 的存在。import json, time class DepthDetector: def __init__(self, weightsyolov8n.pt, conf0.4): self.zed open_camera() self.model YOLO(weights) self.conf conf self.window {} # 按类别做简易中值滤波 def read(self): frame, depth_map grab_pair(self.zed) if frame is None: return [] boxes, clss detect(self.model, frame, self.conf) out [] for box, cid in zip(boxes, clss): d box_distance(depth_map, box) if d is None: continue name self.model.names[int(cid)] hist self.window.setdefault(name, []) hist.append(d) if len(hist) 7: hist.pop(0) out.append({ cls: name, dist: round(sorted(hist)[len(hist) // 2], 3), raw: round(d, 3), box: [int(v) for v in box], ts: time.time(), }) return out这个类有两个我踩过坑才加上的设计。一个是按类别分窗口滤波早期我用全局窗口画面里同时出现人和箱子时两个目标的值互相污染读数乱跳。另一个是保留raw字段调试阶段能一眼看出滤波前后的差异定位问题时非常省事。往业务上接通常有三种形态选哪种取决于下游是谁形态实现方式适用场景进程内调用直接import这个类单机部署逻辑简单本地服务起个 HTTP 服务read()结果转 JSON多个模块共用一路相机消息上报结果序列化后推到消息队列分布式系统需要解耦性能方面yolov8n在普通独显上跑 640 输入大概能到 40 帧以上和深度计算抢 GPU 的时候会掉一些实测下来稳定在 20 到 25 帧之间对避障够用。想再快两条路换 TensorRT 后端掉一半耗时但要注意导出时的动态尺寸或者降检测频率每两帧检测一次中间帧用上一帧的框算距离这个在目标移动慢的场景几乎无损。验证改造有没有成功我有个固定动作拿一段录好的素材回放跑改造前后两版代码把输出按帧对齐 diff 一遍差值超过 1 厘米的帧逐个看。这个习惯是从一次线上读数整体偏移了 40 厘米的事故之后养成的那次的原因是把滤波窗口从 7 改成 9 的时候顺手把中位数取错了索引肉眼完全看不出来。从那以后我每次动这类数值逻辑都强制走一遍回放比对希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。