资讯动态

YOLOv5双目测距实战:标定、SGBM与厘米级深度估计

发布时间:2026/10/25 12:43:40 来源:尧图企业网站定制
简介这份源码包面向计算机视觉学习者与需要快速验证双目测距方案的开发者将YOLOv5目标检测与双目视觉测距整合为可直接运行的工程可用于自动驾驶感知、机器人导航等场景的距离估计实验。包内共106个文件以25个py源码、52个pyc缓存、18个yaml模型与数据配置为主并配有3个sh运行脚本、Dockerfile、预训练权重、示例图片及演示视频压缩包约20.88MB便于按训练、检测、后处理模块理解流程。已有2849人学习下载说明其在目标检测与立体视觉结合方向具有一定参考价值。读者可从中获得端到端工程结构、左右图像匹配与视差计算思路、模型配置与依赖环境线索以及从检测结果到距离输出的完整实现参考适合具备PyTorch与OpenCV基础的中级学习者研读和二次开发。1. 从两张图到一个米级读数这套 YOLOv5 双目测距源码到底解决了什么白天在车间拍一对左右图代码跑完直接给出一串带类别的框和到相机的直线距离误差能压到厘米级——这是我第一次把这套 YOLOv5 双目测距源码完整跑起来时的状态。它把两件原本各干各的事拼在了一起YOLOv5 负责回答「这是什么、在哪」双目几何负责回答「离我多远」深度学习负责检测几何负责量距中间用一张视差图当桥。适合谁做抓取粗定位、AGV 避障前向测距、货架体积粗估这类项目的人手里已经有两张同步图、又不想上激光雷达。不适合要求毫米级绝对精度的场景那是另一套工具的事。2. 把几何关系先钉死ZfB/d 里三个参数哪个最容易错真正动手之前把公式和单位关系固定下来后面 debug 会少走很多弯路。这一章不讲铺开的光学只讲这套源码里你必须认识的四个量以及它们各自从哪来、错一步会怎么翻车。2.1 视差的定义为什么是 x_left 减 x_right理想双目是水平放置、光轴平行的两个相机同一个物点在左右图上成像位置不同横坐标差值就是视差 d x_L - x_R。物点越近视差越大无穷远处视差趋近 0。由相似三角形可得Z f * B / dZ物点到相机光心的深度单位跟 B 一致f焦距单位像素注意不是毫米B基线两个相机光心之间的距离单位毫米d视差单位像素这套源码里f 来自标定得到的cameraMatrix的 fxB 来自双目标定输出的平移向量 T 的模长d 来自 SGBM 生成的视差图。三个量里任何一个量纲不对结果直接差一个数量级而且看起来很像对的因为画面还在动、框还在跟着目标走只是读数离谱。提示OpenCV 的stereoCalibrate返回的 T 是右相机相对左相机的平移向量它的单位和你棋盘格方格边长的单位完全一致。方格填 25毫米T 就是毫米方格填 0.025米T 就是米。中间混一次结果就是 1000 倍的错。2.2 焦距必须用像素且必须和当前分辨率匹配标定出来的 fx 带的是像素单位它跟图像分辨率绑定。常见的翻车是这样标定用 1280×720 跑出一套内参实际部署时为了帧率换成了 640×360fx 还是老的测距结果整体偏大一倍。要么重新标定要么按比例缩放import numpy as np K np.array([[700.0, 0.0, 640.0], [0.0, 700.0, 360.0], [0.0, 0.0, 1.0]]) scale 0.5 # 从 1280x720 降到 640x360 # 只缩 fx/fy 和主点畸变系数与分辨率无关不动 K_scaled K.copy() K_scaled[0, 0] * scale K_scaled[1, 1] * scale K_scaled[0, 2] * scale K_scaled[1, 2] * scale这段的意义是内参矩阵里 fx、fy 和主点 cx、cy 是像素量跟分辨率线性相关畸变系数 k1、k2、p1、p2 是无量纲的归一化坐标系数不需要动。缩放完建议拿一个已知距离的物体回归验证一遍别直接信算术。2.3 深度分辨率随距离平方衰减先算清楚你的量程很多人问这套东西能测多远正确问法是在多远的地方还能保持可接受的精度。对 Z fB/d 求导深度误差与视差误差的关系是ΔZ Z² / (f * B) * Δd以 f700 px、B120 mm、视差误差 1 px 为例真实距离 Z理论视差 d1 px 视差误差带来的深度误差500 mm168.0 px3.0 mm1000 mm84.0 px11.9 mm2000 mm42.0 px47.6 mm3000 mm28.0 px107.1 mm5000 mm16.8 px297.6 mm这张表是选型的核心依据。同样一套硬件1 米处误差一厘米出头5 米处误差三十厘米。如果你的项目要在 5 米外判断有没有人那够用要在 5 米外量货箱尺寸趁早换方案或者加大基线。基线 B 加大能提升精度代价是近处盲区变大、两个相机视场重叠率下降常见做法是 B 取工作距离的 1/10 到 1/5 之间做平衡。2.4 视差搜索范围要按最近工作距离算SGBM 的numDisparities决定它能找到的最大视差也就是最近能测多远。算法只会在这个范围内搜索超出的部分变成空洞。计算方式f_px 700.0 # 标定得到的 fx baseline_mm 120.0 z_min_mm 500.0 # 你希望最近能测到 500mm d_max f_px * baseline_mm / z_min_mm # 168.0 px num_disp int(np.ceil(d_max / 16.0) * 16) # 必须是 16 的整数倍 print(num_disp) # 176numDisparities必须被 16 整除这是 OpenCV 对 SGBM 的硬约束不是风格问题。设小了近处物体整片视差缺失深度算出来是 0 或者乱值设大了计算量上去、远处精度还未必提升。按最近工作距离算出来再往上取整到 16 的倍数是最省事的做法。3. 标定与校正的完整链路棋盘格、stereoCalibrate 到 remap这一章是整套源码能不能用的分水岭。检测和匹配的代码网上到处都是真正决定测距准不准的是这一步。我见过太多人跳过校正直接拿原始图做 SGBM然后抱怨视差图糊成一团其实极线根本没对齐匹配器在错误的行上找对应点。3.1 采集15 对起步覆盖四角两个方向都要倾斜标定板用常见的 9×6 内角点棋盘格方格边长先用卡尺量实际值——打印时选适应页面会把 25 mm 缩成 24.3 mm这个误差会等比传到基线上属于血泪经验。采集要求左右相机同时抓图硬件同步最好软同步至少保证标定板静止不少于 15 对有效图20 对更稳棋盘格要出现在画面四角和中区别只在正中间拍至少两个倾斜方向绕 X 轴、绕 Y 轴各来几组纯正对的图对解畸变帮助很小左右图的棋盘格必须都被完整检出缺一个就丢弃这一对采集完先做一轮筛选把角点检测失败的、模糊的、过曝的剔掉。宁要 15 对干净的不要 30 对带噪的。3.2 单目标定先过关再做双目标定顺序上先分别标左右相机拿到各自的内参和畸变系数检查重投影误差再把内参用CALIB_FIX_INTRINSIC固定住只解 R 和 T。这样两个阶段的问题不会互相污染。import cv2 import numpy as np import glob PATTERN (9, 6) # 内角点列数、行数 SQUARE_MM 25.0 # 卡尺实测的方格边长单位 mm objp np.zeros((PATTERN[0] * PATTERN[1], 3), np.float32) objp[:, :2] np.mgrid[0:PATTERN[0], 0:PATTERN[1]].T.reshape(-1, 2) * SQUARE_MM criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-3) def collect(paths): objpoints, imgpoints [], [] shape None for p in paths: img cv2.imread(p) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) shape gray.shape[::-1] # (w, h) ok, corners cv2.findChessboardCorners(gray, PATTERN, None) if not ok: continue # 检不出就丢别硬塞 corners cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) objpoints.append(objp) imgpoints.append(corners) return objpoints, imgpoints, shape left_paths sorted(glob.glob(data/left/*.png)) right_paths sorted(glob.glob(data/right/*.png)) objL, imgL, shape collect(left_paths) objR, imgR, _ collect(right_paths) assert len(objL) len(objR) 15, 左右有效样本数必须一致且不少于 15 # 单目先各自标看 RMS 是否可接受 rmsL, KL, dL, _, _ cv2.calibrateCamera(objL, imgL, shape, None, None) rmsR, KR, dR, _, _ cv2.calibrateCamera(objR, imgR, shape, None, None) print(left rms, rmsL, right rms, rmsR) # 经验阈值 0.5 px # 双目固定内参只解 R / T flags cv2.CALIB_FIX_INTRINSIC rms, KL, dL, KR, dR, R, T, E, F cv2.stereoCalibrate( objL, imgL, imgR, KL, dL, KR, dR, shape, criteria(cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 100, 1e-6), flagsflags) baseline float(np.linalg.norm(T)) print(stereo rms, rms, baseline_mm, baseline)逻辑说明collect负责把每张图上检出的角点做亚像素精化亚像素这一步不做重投影误差会虚高。calibrateCamera每个相机单独调用返回的 RMS 是重投影误差单位像素超过 1 px 基本说明采集质量有问题先别往下走。stereoCalibrate里固定内参是关键让优化只在 R、T 六个自由度上进行收敛更稳。最后一行np.linalg.norm(T)就是基线单位跟随SQUARE_MM这里是毫米。参数说明cornerSubPix的窗口 (11,11) 是搜索半窗棋盘格在图上比较小的时候可以降到 (5,5)。stereoCalibrate的迭代终止条件给到 100 次和 1e-6比单目标定更严一点因为这里的误差会直接变成基线误差。3.3 校正三段式stereoRectify、initUndistortRectifyMap、remap标定完只是拿到参数真正让左右图行对齐的是校正。三步固定顺序# 1) 求校正变换R1/R2 让两图共面P1/P2 是新投影矩阵Q 是重投影矩阵 R1, R2, P1, P2, Q, roi1, roi2 cv2.stereoRectify( KL, dL, KR, dR, shape, R, T, flagscv2.CALIB_ZERO_DISPARITY, alpha0) # 2) 预计算映射表只做一次别在循环里反复算 map1x, map1y cv2.initUndistortRectifyMap(KL, dL, R1, P1, shape, cv2.CV_32FC1) map2x, map2y cv2.initUndistortRectifyMap(KR, dR, R2, P2, shape, cv2.CV_32FC1) # 3) 每帧 remap def rectify(left, right): l cv2.remap(left, map1x, map1y, cv2.INTER_LINEAR) r cv2.remap(right, map2x, map2y, cv2.INTER_LINEAR) return l, ralpha0表示校正后只保留有效像素画面会被裁掉一圈黑边但不会引入无效区域alpha1保留全部像素但边缘会有黑边做 SGBM 时黑边会产生假视差一般选 0。CALIB_ZERO_DISPARITY保证主点在校正后仍然对齐这样视差搜索从 0 开始就够省一半计算量。映射表一定要在初始化阶段算好initUndistortRectifyMap内部是逐像素查表运算放进每帧循环里会白白吃掉几十毫秒。3.4 验收画极线肉眼确认行对齐校正做没做对不用等测距结果画一组极线就能看出来。在校正后的左图上取若干个点在右图的同一行画横线如果对应点都落在同一行上说明极线对齐了。def check_epipolar(left_rect, right_rect, rows(100, 200, 300, 400)): canvas right_rect.copy() for y in rows: cv2.line(canvas, (0, y), (canvas.shape[1], y), (0, 0, 255), 1) # 上下拼接人工对比同一行的纹理是否对应 return np.vstack([left_rect, canvas]) cv2.imwrite(debug/epipolar_check.png, check_epipolar(*rectify(l_raw, r_raw)))验收标准很朴素同一行上的纹理在左右图里应该能对上偏差控制在 1 到 2 像素以内。如果明显斜着错位回头查两件事——左右图有没有传反以及stereoCalibrate的输入顺序是否和stereoRectify一致。这两处顺序错一次画面能跑读数全废。4. 把 YOLOv5 接上去检测框怎么喂给视差图才不飘检测和测距的耦合点只有一处框。框给得太松框里混进背景视差中值被拉偏框给得太紧有效视差像素太少读数抖。这一章讲清楚取样策略和 SGBM 参数的配合。4.1 检测在左图做坐标直接复用YOLOv5 的推理输出是[x1, y1, x2, y2, conf, cls]全部在输入图的坐标系里。因为校正后的左图和原图分辨率相同remap不改变尺寸检测框可以直接用在校正后的左图上取 ROI不需要额外做坐标变换。这是整套流程里最省事的一环前提是检测和测距用的是同一张左图、同一个分辨率。import torch model torch.hub.load(ultralytics/yolov5, yolov5s, pretrainedTrue) model.conf 0.35 # 置信度阈值按场景调 model.iou 0.45 def detect(img_bgr): results model(img_bgr) # xyxy 是归一化前的像素坐标直接可用 return results.xyxy[0].cpu().numpy() # N x 6 def pick_roi(box, ratio0.5): x1, y1, x2, y2 box[:4].astype(int) w, h x2 - x1, y2 - y1 # 只取框中心 ratio 比例的区域避开框边缘的背景污染 cx, cy x1 w / 2, y1 h / 2 rw, rh int(w * ratio / 2), int(h * ratio / 2) return x1 int(w / 2) - rw, y1 int(h / 2) - rh, x1 int(w / 2) rw, y1 int(h / 2) rhpick_roi里那个 0.5 是很实际的经验值。目标检测框通常比物体本身大一圈尤其是有遮挡或者物体形状不规则的时候框的四角大概率是背景。背景的视差和前景差得远混进中值计算里会把结果往远处拉。取中心一半牺牲一点像素数量换稳定性通常划算小目标可以放宽到 0.7否则有效像素太少。4.2 视差取样用中位数别用均值拿到 ROI 之后在视差图上取同一块区域。SGBM 输出里无效像素通常是负数minDisparity为 0 时无效值常见为 -16 或 0取决于 OpenCV 版本和模式必须先过滤否则一算就崩。def roi_disparity(disp, roi): x1, y1, x2, y2 roi patch disp[y1:y2, x1:x2].astype(np.float32) patch patch[patch 0] # 过滤无效视差 if patch.size 30: # 有效点太少判为不可信 return None # 中位数抗离群点均值会被少量错误匹配拉偏 return float(np.median(patch)) / 16.0 # SGBM 输出放大 16 倍存储 def disparity_to_depth(d, f_px, baseline_mm): if d is None or d 0: return None return f_px * baseline_mm / d # 单位 mm两个细节值得说清楚。第一SGBM 的视差图用CV_16S存储数值是真实视差的 16 倍所以要除以 16忘除的话深度会小 16 倍而且结果看起来挺像那么回事最难发现。第二中位数比均值稳得多ROI 里哪怕有 20% 的噪点中位数基本不动均值会明显偏移。4.3 SGBM 参数按你的基线和分辨率配参数表贴出来每一项都对应一个具体后果参数典型值取值依据与后果minDisparity0配合CALIB_ZERO_DISPARITY非 0 时要同步改深度公式numDisparities176按 2.4 节公式算太小近处缺失太大浪费算力blockSize5奇数越小边缘越锐但噪点多纹理弱的目标给 7P18·cn·bs²cn 为图像通道数bs 为 blockSizeP232·cn·bs²比 P1 大 4 倍是常用起点越大越平滑disp12MaxDiff1左右一致性检查设 0 会砍掉大量有效点uniquenessRatio10提高能滤掉重复纹理带来的误匹配speckleWindowSize100滤掉小块孤立噪点目标小的时候降到 50speckleRange2与上一条配合控制斑点判定的视差跨度def build_matcher(num_disp176, block5): cn 1 # 灰度图 return cv2.StereoSGBM_create( minDisparity0, numDisparitiesnum_disp, blockSizeblock, P18 * cn * block * block, P232 * cn * block * block, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange2, modecv2.STEREO_SGBM_MODE_SGBM_3WAY) matcher build_matcher() disp matcher.compute(left_gray_rect, right_gray_rect)SGBM_3WAY模式比默认模式慢一些但视差更连续静态场景值得换。做匹配前建议对左右图各做一次 CLAHE缓解两个相机曝光不一致导致的匹配失败尤其是自动曝光没锁死的时候这一步能救回不少空洞。4.4 把距离画回画面同时给一个置信度最后一步是把深度值叠回图上。别只画数字把有效视差像素数也标出来方便现场判断这一帧的读数靠不靠谱。def draw(frame, box, depth_mm, valid_cnt): x1, y1, x2, y2 box[:4].astype(int) label f{int(box[5])} if depth_mm: label f {depth_mm/1000:.2f}m color (0, 255, 0) if valid_cnt 200 else (0, 200, 255) else: label N/A color (0, 0, 255) cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) cv2.putText(frame, label, (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) return frame用颜色区分置信度比单纯打印数字有用得多。有效像素数低于阈值时标黄让现场的人一眼看出这帧别信比事后查日志高效。多目标场景下每个框独立算深度互不干扰这是把检测和测距拆开做的好处。5. 避坑排查五个把测距搞成玄学的细节这一章是我踩过的坑里挑出来最费时间的五个。共同点是代码不报错、画面正常、结果就是不对没有日志告诉你哪里错了只能靠推理。5.1 标定和硬件埋的雷现象一所有目标系统性偏大 3% 到 5%换个距离比例基本不变。原因几乎一定是棋盘格的实际尺寸和代码里的不一致。打印机适应页面缩放、纸张受潮、测量时量的外边缘而不是内角点间距都会引入比例误差。这个误差会等比传到 T 向量的模长上也就是基线于是所有深度同比例偏。解决用卡尺量三个以上方格取平均把真实值填进SQUARE_MM。验证方法很简单把棋盘格放在一个已知距离卷尺量比如 1500 mm跑一遍测距看读数偏差是否在可接受范围。偏差是比例性的就改方格尺寸是固定偏移的就查主点。现象二单目标定 RMS 只有 0.2 px双目标定结果却很差极线怎么都调不齐。原因通常是采集时左右图不同步。标定板在移动过程中被左右相机在不同时刻拍到角点位置对不上stereoCalibrate解出来的 R、T 就是错的。软同步在静态拍摄时没问题一旦手持移动就会翻车。解决标定时把板子放稳再触发或者上硬件同步线。另外确认左右图的文件名严格一一对应用sorted(glob)之后还要做一次长度和内容校验中途丢一张图就会错位。现象三视差图整片是黑的或者只有零星几个点。先排除左右图接反。校正后左右图传反了视差会全为负被patch 0全部过滤掉。检查方法把两张图并排看左边图的物体应该比右边图偏右一点点对近处物体而言。还有一种情况是画面纹理太少白墙、纯色纸箱、金属反光面SGBM 在无纹理区域本来就没辙。解决接反了就换回来并同步检查stereoCalibrate和stereoRectify的输入顺序。纹理不足就加辅助投一个散斑投影器是最直接的办法成本很低或者退而求其次用检测框边缘的纹理通常是有的来估别指望框中心一定是好纹理。5.2 匹配和融合阶段的坑现象四目标边缘深度跳变剧烈同一个箱子前面读 1.2 m、侧面读 2.5 m。这是典型的前后景粘连。SGBM 的匹配窗口横跨了深度不连续处窗口内一半是前景一半是背景匹配代价被平均结果落在中间。blockSize越大越明显P2越大越平滑也越容易糊边。解决把blockSize降到 5 甚至 3代价是视差图噪点变多同时把取样区域从整框缩到中心避开物体轮廓。如果目标是规则物体箱子、托盘还可以只取框下半部分因为上半部分常常是背景。现象五距离读数一直在小幅跳动同一目标静止不动读数在 ±15 cm 之间来回摆。三个可能叠在一起检测框本身在抖逐帧之间框的位置差几个像素取样用了均值被离群点拉以及视差图本身的量化噪声。单帧读数本来就不该被直接使用。解决检测框做时间维平滑简单的指数移动平均就够深度读数同样做 EMAclass DepthSmoother: def __init__(self, alpha0.3, max_jump_mm800): self.alpha alpha self.prev {} self.max_jump max_jump_mm def update(self, track_id, depth_mm): if depth_mm is None: return self.prev.get(track_id) p self.prev.get(track_id) if p is None or abs(depth_mm - p) self.max_jump: self.prev[track_id] depth_mm # 首帧或突变直接重置 else: self.prev[track_id] self.alpha * depth_mm (1 - self.alpha) * p return self.prev[track_id]alpha取 0.3 意味着新观测占三成响应不算慢同时能压掉大部分抖动。max_jump是突变保护防止目标真的快速靠近时被平滑拖住。目标跟踪 ID 可以用简单的 IOU 匹配串起来不用上复杂的跟踪器。6. 精度验证与工程化收尾用卷尺和一段录像把误差压下去代码跑通只是起点能不能交付看的是误差曲线。我一般会做一张实测表把标定板或者一个平面目标放在 0.5 m、1 m、2 m、3 m、4 m 五个位置每个位置用卷尺量真实距离代码连测 30 帧取中位数算绝对误差和相对误差。真实距离中位读数绝对误差相对误差备注500 mm待填待填待填接近盲区注意视差是否溢出搜索范围1000 mm待填待填待填主力工作区间2000 mm待填待填待填精度开始明显下滑3000 mm待填待填待填关注是否还有足够有效像素4000 mm待填待填待填超出设计量程就标注为不可用表格填完会有两个发现。第一误差基本遵循第 2 章的平方关系如果某一段突然跳变说明那个距离的视差正好落在numDisparities边界附近回去把范围放宽一档。第二相对误差在近处往往比理论值大因为近距离时物体占的像素多、框内混进的背景比例也高取样比例要相应收紧。验证方法上除了静态点位我还会录一段手持缓慢靠近目标的视频跑离线批处理把每帧的读数打出来看曲线是否单调。如果曲线出现台阶状跳变多半是视差图里主峰发生了切换这时候uniquenessRatio往上调 2 到 3 通常能缓解。反过来如果曲线过于平滑、跟不上真实运动说明 EMA 的alpha给小了或者P2给大了把边缘糊掉了。工程化收尾还有两件事值得提前做。一是把标定参数存成npz加载时校验shape和当前分辨率是否一致不一致直接报错退出别让它悄悄跑出错误结果。二是给 SGBM 加一个 ROI 版本先用检测框把左右图裁出一个小区域再做匹配计算量能降一个数量级帧率从 15 fps 提到 40 fps 以上代价是只对框内目标有视差整幅场景的视差图就没有了——如果下游只用框内距离这笔买卖很划算。# 参数持久化 分辨率一致性校验 np.savez(calib/stereo.npz, K1KL, D1dL, K2KR, D2dR, RR, TT, shapeshape) def load_calib(path, cur_shape): z np.load(path, allow_pickleTrue) saved tuple(z[shape]) if saved ! tuple(cur_shape): raise ValueError(f标定分辨率 {saved} 与当前 {cur_shape} 不一致请重标或缩放内参) return z[K1], z[D1], z[K2], z[D2], z[R], z[T]这个校验看着啰嗦但它替我挡过至少两次事故——一次是换相机忘了重标一次是推理脚本里 resize 写错了参数。那次之后我每次接入新硬件第一件事就是跑一遍分辨率校验加一组卷尺点位不管多赶时间都不跳过。测距这种东西错的时候不报错只会安静地给你一个看起来很合理的数字。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑