简介:一套完整的双目测距项目实战源码,基于YOLOX目标检测算法实现双目相机的测距功能,面向计算机视觉方向学习者、研究者与工程开发人员,可用于教学演示、算法研究及实际项目落地。资源共47个文件,以31个Python源文件为主,辅以图像样例、标注文本、依赖清单与日志等,压缩包整体仅2.12MB,结构紧凑但流程完整。已有208人学习浏览,适合希望从零搭建双目测距原型或深入理解YOLOX与立体视觉结合的开发者。项目覆盖图像采集、预处理、目标检测、立体匹配、视差计算到距离转换的全流程,源码对关键步骤均做了详细注释,并附带单点/三点距离预测脚本、VOC标注工具与训练脚本,便于二次开发与参数调试,具备较强的实践参考价值。
1. 双目测距与YOLOX:这套项目到底解决了什么问题
把 YOLOX 目标检测和双目测距绑在一起做,是很多视觉项目从“能识别”走向“能定位”的必经一步。单目相机只能告诉你画面里有什么,而双目相机通过左右两块 sensor 的视差,能算出目标到相机的真实距离。这个项目标题的核心,就是让你拿到一套带源码的完整基线:左相机和右相机先标定好,再用 YOLOX 检测出目标框,然后把检测框的位置映射到视差图上,换算出目标的距离。适合正在做机器人避障、安防监控测距、工业抓取定位,或者毕业设计需要“检测+测距”完整闭环的工程师直接复用。
这套方案里最容易被忽略的是视差图和检测框的坐标对齐,以及标定误差在距离上的放大效应,这两处也正是很多人在源码基础上改到一半翻车的地方。我下面按自己的落地路径,把原理、标定、映射和排查串起来讲一遍。
2. 双目测距原理与 YOLOX 选型:为什么这两个技术能焊在一起
2.1 视差与深度:三角测量的核心公式
双目测距的根本不是“两个摄像头看同一个物体”,而是利用左右相机对同一三维点的成像差异来计算深度。这个差异叫视差(disparity),单位是像素。假设左右相机光心距为基线 B,焦距为 f,某点在左图成像为 x_L、右图成像为 x_R,那么它到相机平面的距离 Z 满足:
Z = (B × f) / (x_L - x_R)
这里的 x_L - x_R 就是视差。这个公式是整个双目系统的地基。注意它假设左右相机已经做过立体校正,两条极线是水平的、y 坐标一致,这样视差才简化为纯 x 方向差值。否则你要做极线搜索,问题会复杂很多。
实际工程里,焦距 f 和基线 B 来自相机标定参数,而视差图由立体匹配算法(最常用的是 OpenCV 的 SGBM)生成。视差图上每个像素的值就是该像素对应的视差。距离越近的物体,视差值越大;距离越远,视差值趋近于 0。所以深度精度随距离增长而急剧恶化——5 米外 1 个像素的视差误差,带来的距离误差可能是 10 厘米级别。
这套关系决定了整个项目的最关键工程点:想让远距离目标测距更准,光换更好的目标检测模型没用,你的视觉精度瓶颈在标定和立体匹配,而不在 YOLOX。这是我在实际项目里走过弯路之后才有的体会。标定和视差匹配是决定距离精度的两道天花板,YOLOX 的作用只是告诉你在视差图的哪个位置取值。
提示:Z = Bf/d 里的 B、f 必须用标定后的实际值,别用出厂标称值。出厂标称在温度、装配应力影响下会漂移,哪怕漂移 1% 的基线,10 米外目标的距离误差也会不可接受。
2.2 为什么选 YOLOX 做目标检测:anchor-free 与 Decoupled Head
YOLOX 在双目标题里出现不是偶然。它采用 anchor-free 设计,相比之前 YOLO 系列的 anchor-based 方式,省去了为数据集设计 anchor 尺寸的先验工作。你要检测的物体是“人”还是“车辆”还是“机械臂抓取的工件”,YOLOX 都能直接在你标注的数据上训练,不用手工调 9 组 anchor 尺寸,这对项目快速验证很友好。
另一个关键点是 Decoupled Head。老 YOLO 把分类和回归任务共享同一个卷积头,YOLOX 把分类分支和回归分支拆开,每个分支用独立的卷积输出。这对端到端部署的意义在于:分类和定位的梯度互不干扰,收敛稳定性更好,尤其是小目标检测时,回归分支不会因为分类 loss 的震荡而被带偏。虽然推理时会多几个卷积层,但以当前 GPU 算力来说,这个开销完全可接受。
还有 SimOTA 正样本匹配策略,它动态为每个目标选择正样本 anchor,而不是按固定 IoU 阈值。实际效果是,当一个画面里既有近处大目标又有远处小目标时,SimOTA 会自适应给每个目标分配适当的样本数,检测召回率在拥挤场景下明显优于固定阈值方案。如果你在做行人密集区域的双目测距,这一条会直接影响你后续测距时“检测框漏检导致没法取值”的概率。
所以我为什么选 YOLOX 而不是 YOLOv5 或 v8 来配合双目测距?核心理由有三个:源码结构清晰,改检测头输出方便;精度和速度平衡好,在嵌入式设备和桌面 GPU 上都能跑;以及项目源码里已经提供了 YOLOX 与后续视差映射的衔接代码,你不需要自己从零写目标框到视差图的坐标变换。如果你要替换成其他检测器,理论上可行,但坐标对齐和特征图尺度这两处要重新适配,工作量不小。
3. 双目相机标定与立体校正:从双目标定得到可用视差的基础
3.1 双目标定流程与关键命令
拿到项目源码第一件事不是跑检测,而是先确认相机标定参数能不能用。项目里一般会提供已经标定好的参数文件,但如果你换了一台相机,或者左右相机之间的结构出现过磕碰、松动,就必须重新标定。
双目标定我惯用的流程是准备一块 8x6 或者 7x8 的棋盘格标定板(内角点尺寸不同,OpenCV 的 findChessboardCorners 参数要同步改),打印后贴在硬质平板上,忌讳用软纸板。然后左右相机同步采集 20-30 张不同位姿的标定图像。每张图里棋盘格要出现在画面的不同位置和角度,特别是边缘区域,否则内参在图像边缘的畸变系数拟合会严重失真。
下面是双目标定的核心 Python 流程。用 OpenCV 时,最终要得到的是左相机内参矩阵 M1、畸变系数 D1,右相机内参 M2、D2,以及右相机相对于左相机的旋转矩阵 R 和平移向量 T。
import cv2 import numpy as np import glob # 棋盘格内角点尺寸,例如 9x6 表示每行 9 个内角点、每列 6 个 CHECKERBOARD = (9, 6) square_size = 0.026 # 每个棋盘格的实际边长,单位米 # 生成棋盘格三维点坐标(z=0) objp = np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32) objp[:, :2] = np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2) objp *= square_size # 分别存放左右图像中的二维角点 imgpoints_l = [] imgpoints_r = [] objpoints = [] left_imgs = sorted(glob.glob('calib_left/*.png')) right_imgs = sorted(glob.glob('calib_right/*.png')) for lf, rf in zip(left_imgs, right_imgs): img_l = cv2.imread(lf) img_r = cv2.imread(rf) gray_l = cv2.cvtColor(img_l, cv2.COLOR_BGR2GRAY) gray_r = cv2.cvtColor(img_r, cv2.COLOR_BGR2GRAY) ret_l, corners_l = cv2.findChessboardCorners(gray_l, CHECKERBOARD, None) ret_r, corners_r = cv2.findChessboardCorners(gray_r, CHECKERBOARD, None) if ret_l and ret_r: criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners_l = cv2.cornerSubPix(gray_l, corners_l, (11, 11), (-1, -1), criteria) corners_r = cv2.cornerSubPix(gray_r, corners_r, (11, 11), (-1, -1), criteria) objpoints.append(objp) imgpoints_l.append(corners_l) imgpoints_r.append(corners_r) # 单目标定:分别求左右相机内参和畸变 ret_l, M1, D1, rvecs_l, tvecs_l = cv2.calibrateCamera( objpoints, imgpoints_l, gray_l.shape[::-1], None, None) ret_r, M2, D2, rvecs_r, tvecs_r = cv2.calibrateCamera( objpoints, imgpoints_r, gray_r.shape[::-1], None, None) # 双目标定:求右相机到左相机的 R、T ret_stereo, M1, D1, M2, D2, R, T, E, F = cv2.stereoCalibrate( objpoints, imgpoints_l, imgpoints_r, M1, D1, M2, D2, gray_l.shape[::-1])上面这段代码里,findChessboardCorners返回的角点顺序必须左右一致,否则后续双目标定会得到错误的外参。cornerSubPix用亚像素精化把角点定位到 0.1 像素以内,这一步对后续视差精度有直接影响,不要省。stereoCalibrate输出的 R 和 T 是右相机光心相对于左相机光心的位姿关系,基线长度就是 T 的模长。
棋盘格边长square_size必须和你实际打印的尺寸一致。很多人标定出来内参合理,但测距系统性的偏差半米,就是因为打印时缩放了棋盘格,代码里还写着原来的边长。我自己的习惯是每张标定板都实际用尺子量一遍,写进代码。
3.2 立体校正与重投影矩阵 Q
双目标定得到 R、T 之后,还不能直接算视差。左右相机实际安装很难做到光轴完全平行,所以要通过立体校正把左右图像变换到“虚拟平行光轴”状态下。OpenCV 的stereoRectify会结合 R、T 计算出左右视图的重映射表,同时输出一个 4x4 的重投影矩阵 Q。
Q 矩阵非常关键,它的第 3 行第 4 列一般是基线长度的倒数,作用是把视差值直接投影成三维坐标。你后面用reprojectImageTo3D时,Q 矩阵直接决定深度值的尺度。若 Q 矩阵里基线单位不对,距离就会整体偏移。
# 图像分辨率,必须与采集图像一致 image_size = gray_l.shape[::-1] # 立体校正,alpha 参数控制裁剪范围,0~1 之间 R1, R2, P1, P2, Q, valid_roi1, valid_roi2 = cv2.stereoRectify( M1, D1, M2, D2, image_size, R, T, alpha=0) # 计算左右视图的映射表 map1_l, map2_l = cv2.initUndistortRectifyMap( M1, D1, R1, P1, image_size, cv2.CV_32FC1) map1_r, map2_r = cv2.initUndistortRectifyMap( M2, D2, R2, P2, image_size, cv2.CV_32FC1)alpha=0表示校正后只保留左右图像重叠区域,边缘会被裁剪,画面会变小,但裁剪后剩下区域几乎都是有效视差区域。alpha=1保留全部原始像素,但边缘会出现黑边,视差值无效。我一般取 0 或 0.5,具体看你是否需要保留更宽的水平视野。
校正之后,一定要做一次验证:取同一时刻的左右图,用cv2.hconcat把两图并排显示,观察同一特征点的 y 坐标是否在同一水平线上。如果 y 坐标差超过 1~2 像素,说明标定质量有问题,后面的视差图会出现大量横向噪声,检测框映射的深度值跳变幅度会很大。
4. 用 YOLOX 检测目标并映射深度:代码实现
4.1 加载 YOLOX 模型并检测目标
在项目里,YOLOX 一般以 PyTorch 权重或 ONNX 形式存在。先把检测器封装成一个类,输入左侧相机图像,输出检测框列表。这里要留意:检测框坐标对应的图像分辨率,必须和视差图分辨率一致,否则映射会全部偏掉。
import cv2 import numpy as np import onnxruntime as ort class YOLOXDetector: def __init__(self, onnx_path, input_size=(640, 640), conf_thres=0.3, nms_thres=0.65): self.session = ort.InferenceSession(onnx_path) self.input_size = input_size self.conf_thres = conf_thres self.nms_thres = nms_thres self.class_names = ["person", "car", "truck", "bicycle"] # 按项目实际类别修改 def preprocess(self, img_bgr): # 保持宽高比的 letterbox 填充 h, w = img_bgr.shape[:2] scale = min(self.input_size[0] / h, self.input_size[1] / w) new_w, new_h = int(round(w * scale)), int(round(h * scale)) resized = cv2.resize(img_bgr, (new_w, new_h)) canvas = np.full((self.input_size[0], self.input_size[1], 3), 114, dtype=np.uint8) canvas[:new_h, :new_w] = resized # 归一化到 0~1 并转 CHW input_tensor = canvas.astype(np.float32) / 255.0 input_tensor = input_tensor.transpose(2, 0, 1)[None, ...] return input_tensor, scale, pad_w, pad_h def detect(self, img_bgr): input_tensor, scale, pad_w, pad_h = self.preprocess(img_bgr) outputs = self.session.run(None, {self.session.get_inputs()[0].name: input_tensor})[0] # 后处理: 解码框 + NMS,输出 xyxy 格式(原始分辨率坐标系) boxes = self.postprocess(outputs[0], scale, pad_w, pad_h) return boxes上面的preprocess使用 letterbox 保持宽高比,而不是直接拉伸。直接拉伸会改变目标的宽高比例,YOLOX 的回归分支输出虽然也能出框,但框的准确性会降低,关键的映射点(比如框中心点)可能会偏移几个像素。postprocess里需要做反向坐标映射,把 640x640 坐标系下的框还原回原始分辨率。这一步做错,距离测出来会整体偏差。
4.2 将检测框坐标映射到视差图
拿到 YOLOX 的检测框后,下一步不是直接取框中心点的视差值,因为单个像素的视差噪声极大,特别是在低纹理区域和物体边缘。更稳的做法是在框内取一块有效区域,计算该区域的视差中值或平均值。我习惯用中值,因为中值对离群点不敏感。比如框中心周围 20% 宽高的矩形区域,内部对视差值做直方图统计,去掉接近 0 和极大值的异常点再取中值。
def box_to_depth(box, disp_map, q_matrix, roi_ratio=0.2): x1, y1, x2, y2 = box roi_w = int((x2 - x1) * roi_ratio) roi_h = int((y2 - y1) * roi_ratio) cx = (x1 + x2) // 2 cy = (y1 + y2) // 2 # 在框中心区域截取 ROI roi_x1 = max(0, cx - roi_w // 2) roi_x2 = min(disp_map.shape[1], cx + roi_w // 2) roi_y1 = max(0, cy - roi_h // 2) roi_y2 = min(disp_map.shape[0], cy + roi_h // 2) roi = disp_map[roi_y1:roi_y2, roi_x1:roi_x2] valid = roi[(roi > 0) & (roi < 192)] # 过滤无效和错误视差 if valid.size == 0: return None disp_median = np.median(valid) # Q 矩阵的第 3 行第 4 列是基线相关项 depth = Q[2, 3] / disp_median return depth这段代码里最核心的是Q[2, 3] / disp_median。OpenCV 的reprojectImageTo3D内部用的就是这个关系。如果你把 Q[2,3] 里的值打印出来,它会等于-4 * fx * Tx相关的值,单位是像素乘以米。所以disp_median必须是以像素为单位的视差值,不能是归一化后的浮点值。
ROI 区域取 20% 宽高是我在多组室内外场景下调出来的平衡点:取太大了,会把物体边缘背景的低质量视差混入;取太小了,单个像素噪声主导。遇到细长目标的检测框(比如一个站在远处的人),中心 ROI 里可能大部分是背景,这个时候可以退而求其次,用框内整体区域做分位数截断再取均值。
4.3 完整测距流程与参数说明
把前面几段组装起来,一次完整的测距过程就是:读左相机图像 → YOLOX 检测 → 读右相机图像并做立体校正 → SGBM 算视差图 → 对每个检测框取 ROI 视差中值 → 通过 Q 矩阵换算距离。实际部署时,左右相机的采集要尽量同步,哪怕是软触发,也要保证左右帧的时间戳差小于 10ms。否则运动目标会出现左右图不对齐,视差图直接出错。
def run_stereo_depth_pipeline(left_img, right_img, detector, stereo_builder, q_matrix): # 1. 目标检测,只处理左图 det_boxes = detector.detect(left_img) # 2. 立体校正 left_rect = cv2.remap(left_img, stereo_builder['map1_l'], stereo_builder['map2_l'], cv2.INTER_LINEAR) right_rect = cv2.remap(right_img, stereo_builder['map1_r'], stereo_builder['map2_r'], cv2.INTER_LINEAR) # 3. 灰度化 + SGBM 视差 gray_l = cv2.cvtColor(left_rect, cv2.COLOR_BGR2GRAY) gray_r = cv2.cvtColor(right_rect, cv2.COLOR_BGR2GRAY) disp = stereo_builder['sgbm'].compute(gray_l, gray_r).astype(np.float32) / 16.0 # 4. 逐框测距 results = [] for box in det_boxes: depth = box_to_depth(box, disp, q_matrix) results.append((box, depth)) # 5. 在左图上绘制框和距离 for box, depth in results: if depth is None: continue x1, y1, x2, y2 = box cv2.rectangle(left_rect, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(left_rect, f"{depth:.2f}m", (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) return left_rect, resultsSGBM 的参数numDisparities和blockSize是影响视差图质量最明显的两个值。numDisparities必须能被 16 整除,它代表了最大视差搜索范围。你的相机基线越长、最近可测距离越近,需要的搜索范围就越大。blockSize是匹配窗口的边长,通常取 5、9、15。窗口太小,低纹理区域全是噪声;窗口太大,物体边缘的视差值会被周围背景污染,导致测距在目标边缘处跳变。
SGBM 算出来的是 16 倍整型视差值,所以要除以 16 还原成像素级真实视差。很多人在这一步忘了除,导致所有距离直接缩小 16 倍,然后满世界找标定问题。这是我见过这个项目里最高发的翻车现场之一。
5. 避坑与常见问题:标定、视差、检测框对齐的坑
5.1 距离值整体偏大或偏小,比例恒定
现象:同一个物体,无论远近,测出来的距离总是比实际距离大约 10% 或小 10%。你检查了代码里的公式,发现逻辑完全没问题。
原因:棋盘格边长square_size和实际打印尺寸不一致,或基线 T 的模长被缩放。Q 矩阵里的基线项直接用错了。还有一种可能是 SGBM 的视差值没有除以 16,但这种一般是偏小 16 倍,很容易看出来。
解决:重新测量标定板实际边长并同步修改代码,重新跑双目标定。如果不想完全重标,可以打印 Q 矩阵里Q[3,2]或Q[2,3],用标称基线值和标称焦距手动反推一遍,确认 Q 矩阵内部的一致性。
5.2 近处距离准,远处飘得离谱
现象:1~2 米内测距还挺准,3 米外误差快速增大,5 米外基本不能用。
原因:这是物理精度限制,不是 bug。视差量化到像素后,距离越远视差值越小,一个像素的量化误差在远处对应的距离误差急剧增大。比如基线 10cm、焦距 800 像素,在 10 米处一个像素视差误差对应约 1.25 米距离误差。
解决:想办法提升视差精度。一是增大基线,把双目间距从 6cm 改成 12cm,精度能提升近一倍;二是用更高分辨率的相机,同样的视场下像素间距更小;三是用亚像素立体重建,SGBM 无法满足时换用 ELAS 或者深度学习立体匹配模型。同时,在代码里可以过滤掉视差小于阈值的检测框,例如视差低于 5 像素的距离不输出,只测距到可信任范围。
5.3 检测框和视差图位置对不上,距离打在背景上
现象:检测框明明框住了人,但 ROI 区域取到的深度值是背景墙的距离。人物边缘尤其明显。
原因:YOLOX 输出的检测框坐标是基于校正前的左图,而视差图是基于校正后的左图。如果你直接拿校正前坐标去索引校正后的视差图,位置偏移会在图像边缘变得非常显著。
解决:要么先用校正后的左图跑检测,要么对检测框坐标做同样的 remap 变换。我习惯的方案是先对原图做立体校正,再喂给 YOLOX,这样检测和视差天然对齐。如果担心校正会裁剪画面导致检测性能下降,也可以做反向映射,但代码复杂度会明显增加。
提示:无论用哪种方案,都建议在项目里加一个调试开关:把检测框画在校正后的左图上,和视差图叠加显示,肉眼确认边缘对齐后再批量跑数据。这一步能省几天的排查时间。
5.4 光照变化导致视差图大面积变黑
现象:室内开灯和关灯时,同一位置的视差图差异巨大,质量差的时候目标区域全是无效值。
原因:SGBM 对光照变化敏感,它依赖像素亮度的一致性。左右相机自动曝光参数不一致,或者室内有频闪光源(LED 灯 100Hz 闪烁),都会导致左右图亮度跳变。
解决:在标定前就把左右相机的曝光时间、增益、白平衡全部手动固定,禁止自动曝光。室外场景如果顺光和逆光切换,也需要用同一组曝光参数,优先保证两个相机尽量一致,而不是单帧过曝正常。如果项目允许,用红外结构光补光是最稳的做法。
5.5 检测框中心点落在低纹理区域,视差值取不到
现象:目标框能检测出来,但 ROI 内的视差有效像素非常少,导致取中值时得到 NaN 或跳变的距离。
原因:纯色物体表面、白墙、天空等区域没有纹理信息,立体匹配找不到匹配点,视差值为 0 或随机值。
解决:扩大 ROI 区域或者改为从框内整体采样,用分位数截断(比如取 10%~90% 之间的像素)后再求均值。更根本的办法是换用全局匹配算法或者带边缘保持的立体匹配方案。另外,测距时优先选择目标身上的纹理区(比如人身上的衣服边缘)而不是框中心,也可以提高稳定性。实际做项目时我会在代码里把“可见视差像素占比”作为置信度输出,低于 30% 的框直接标为不可信。
6. 验证与进阶:在室外场景让测距更稳
拿到源码跑通基线之后,第一件事不是急着调参,而是做一次系统性验证。找一个空旷场地,从 1 米到 15 米每隔 0.5 米放一个标志物,把双目相机固定好,用实际卷尺量出真实距离,同时记录系统输出的距离值。把这两组数据画成曲线。理想情况下,整个曲线应该贴近 y=x 直线,偏差随距离增大而增大。如果曲线系统性偏移,大概率是基线标定问题;如果曲线在某个距离段突然跳变,大概率是视差搜索范围或者 SGBM 参数覆盖不够。
室外场景最常见的杀手是太阳光直射导致的过曝和阴影硬边界,会让左右图的亮度分布不再一致。推荐的做法是把相机增益固定为最小值,用光圈或 ND 滤镜控制进光量,而不是靠自动曝光。另一个要注意的是风引起的相机支架抖动。双目相机的基线哪怕只有毫米级的形变,对远处目标的测距影响都会被放大。所以室外部署时,相机支架建议用三角架配合束紧带固定,不要用手持或者夹子在风中晃动。
进阶方向上,有两个性价比很高的改进。第一是把 SGBM 的视差图做中值滤波(5x5 或 7x7 窗口),能消除带状噪声但不明显影响边缘,注意滤波窗口不要大于检测框 ROI 尺寸。第二是加入帧间深度滤波,对一个跟踪中的目标,用 Kalman 滤波器对距离做平滑。YOLOX 单独检测每一帧,距离会上下跳,但 Kalman 能有效抑制随机跳动,输出更符合实际机器人控制需求。
另一个我强烈建议尝试的思路是把 YOLOX 的检测置信度作为深度滤波的权重。当目标有遮挡或者运动模糊时,置信度会下降,此时应该相信历史深度值多一点,而不是当前帧的突变值。这个逻辑在室外人车混行场景特别管用。我自己的习惯是始终在测距结果里附加一个“深度可信度”字段,由视差有效像素比例和检测置信度共同计算,下游决策模块只消费可信度高于阈值的数据。这样即便个别帧测距翻了车,整个系统也不会做出错误避障行为。
这套项目做完后你会发现,真正核心的竞争力不在于目标检测模型本身,而在于标定质量和视差图质量。YOLOX 在这里更多是一个“定位器”——告诉你该在哪里测距,至于测出来的距离准不准,百分之八十的决定因素在标定和立体匹配。希望这篇笔记能帮你少走点弯路,把精力花在真正决定结果的地方。
本文还有配套的精品资源,点击获取