双目测距与YOLOX目标检测:从视差原理到深度估计的完整工程实践 简介一套完整的双目测距项目实战源码基于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 HeadYOLOX 在双目标题里出现不是偶然。它采用 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 # 每个棋盘格的实际边长单位米 # 生成棋盘格三维点坐标z0 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, alpha0) # 计算左右视图的映射表 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)alpha0表示校正后只保留左右图像重叠区域边缘会被裁剪画面会变小但裁剪后剩下区域几乎都是有效视差区域。alpha1保留全部原始像素但边缘会出现黑边视差值无效。我一般取 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_thres0.3, nms_thres0.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, dtypenp.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_ratio0.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 米放一个标志物把双目相机固定好用实际卷尺量出真实距离同时记录系统输出的距离值。把这两组数据画成曲线。理想情况下整个曲线应该贴近 yx 直线偏差随距离增大而增大。如果曲线系统性偏移大概率是基线标定问题如果曲线在某个距离段突然跳变大概率是视差搜索范围或者 SGBM 参数覆盖不够。室外场景最常见的杀手是太阳光直射导致的过曝和阴影硬边界会让左右图的亮度分布不再一致。推荐的做法是把相机增益固定为最小值用光圈或 ND 滤镜控制进光量而不是靠自动曝光。另一个要注意的是风引起的相机支架抖动。双目相机的基线哪怕只有毫米级的形变对远处目标的测距影响都会被放大。所以室外部署时相机支架建议用三角架配合束紧带固定不要用手持或者夹子在风中晃动。进阶方向上有两个性价比很高的改进。第一是把 SGBM 的视差图做中值滤波5x5 或 7x7 窗口能消除带状噪声但不明显影响边缘注意滤波窗口不要大于检测框 ROI 尺寸。第二是加入帧间深度滤波对一个跟踪中的目标用 Kalman 滤波器对距离做平滑。YOLOX 单独检测每一帧距离会上下跳但 Kalman 能有效抑制随机跳动输出更符合实际机器人控制需求。另一个我强烈建议尝试的思路是把 YOLOX 的检测置信度作为深度滤波的权重。当目标有遮挡或者运动模糊时置信度会下降此时应该相信历史深度值多一点而不是当前帧的突变值。这个逻辑在室外人车混行场景特别管用。我自己的习惯是始终在测距结果里附加一个“深度可信度”字段由视差有效像素比例和检测置信度共同计算下游决策模块只消费可信度高于阈值的数据。这样即便个别帧测距翻了车整个系统也不会做出错误避障行为。这套项目做完后你会发现真正核心的竞争力不在于目标检测模型本身而在于标定质量和视差图质量。YOLOX 在这里更多是一个“定位器”——告诉你该在哪里测距至于测出来的距离准不准百分之八十的决定因素在标定和立体匹配。希望这篇笔记能帮你少走点弯路把精力花在真正决定结果的地方。本文还有配套的精品资源点击获取