简介:面向计算机视觉开发者的一份实战项目资源,基于YOLOX目标检测算法实现双目相机测距,提供从图像采集、目标检测、立体匹配到距离转换的完整流程,适用于教学、研究以及机器人导航、自动驾驶等场景。压缩包共47个文件,以31个Python源码文件为主,覆盖模型训练、目标检测、单点/多点距离预测及辅助工具等模块,并附带bmp/jpg测试图像、训练验证标签、说明文档与日志文件,整体大小约2.12MB,轻量易用。已有208人学习下载。源码对关键步骤作了详细注释,内置可调参数的用户界面,通过具体实战案例演示系统搭建、参数调优与功能测试全过程,可帮助开发者同步掌握YOLOX算法原理与双目视觉测距的工程实现。项目结构清晰,便于按需查阅和二次开发,不仅具有教学示范价值,也能为机器人导航、自动驾驶等实际应用提供快速落地的参考。
1. 双目测距遇上YOLOX:为什么目标检测框的深度值才靠谱
做双目测距最头疼的不是相机标定,也不是深度图计算,而是“算出深度之后怎么告诉用户前面那辆车有多远”。如果你直接对整幅深度图取平均值,那对不起,背景里的电线杆、地面、树叶全都混进来,距离值能偏出一辆车那么长。把YOLOX目标检测框和双目视差结合起来,本质上是做一件事:先让检测网络告诉你“哪个像素区域属于目标”,再让双目系统告诉你“这个区域离我多远”。两条技术路线各管一段,检测负责语义,测距负责几何,组合起来才是一个能上车的距离感知方案。这篇笔记适合已经跑通过YOLOX目标检测、想给检测框加上真实距离值的开发者,也适合打算从零做双目测距项目、需要一份可靠落地路径的初学者。我会按标定、检测框到深度图的映射、参数调试、避坑、完整脚本骨架的顺序讲清楚,最后给你几个能把误差压到厘米级的实操技巧。全程不碰黑匣子,每个参数都能调,每步都能复现。
2. 先搞定双目相机标定:内参、畸变与立体校正
2.1 标定板采集与标定命令
双目测距的第一步不是YOLOX,而是让左右两个相机“认识彼此”。很多初学者的翻车点就在这里:直接用厂家给的标称焦距和两相机间距,结果深度图像被揉过的纸团,检测框再准也没用。正规做法是拿标定板,拍20对以上左右目同步帧,用OpenCV的stereoCalibrate同时求解左右相机内参、畸变系数和两相机间的旋转矩阵R、平移向量T。
我一般把采集和标定拆成两个脚本:采集时用cv2.VideoCapture同时打开两个摄像头,按间隔帧保存左右图对。这里有个容易忽略的细节——两个摄像头最好用同一型号,在硬件上固定同步触发;如果没有同步触发,至少要在采集时保证场景静止,否则标定板动一下,特征点对不上,标定结果直接作废。下面是一段采集脚本的核心逻辑:
import cv2 import os left_cap = cv2.VideoCapture(0) right_cap = cv2.VideoCapture(1) os.makedirs('left', exist_ok=True) os.makedirs('right', exist_ok=True) frame_id = 0 while frame_id < 30: # 建议采集30对以上,越多越好 ret_l, left = left_cap.read() ret_r, right = right_cap.read() if not (ret_l and ret_r): continue # 转为灰度后找棋盘格角点,能用才保存,避免无效数据 gray_l = cv2.cvtColor(left, cv2.COLOR_BGR2GRAY) gray_r = cv2.cvtColor(right, cv2.COLOR_BGR2GRAY) ret_l_found, corners_l = cv2.findChessboardCorners(gray_l, (9, 6), None) ret_r_found, corners_r = cv2.findChessboardCorners(gray_r, (9, 6), None) if ret_l_found and ret_r_found: cv2.imwrite(f'left/{frame_id:04d}.png', left) cv2.imwrite(f'right/{frame_id:04d}.png', right) frame_id += 1 print(f'captured {frame_id} pairs') # 显示带角点的画面,方便调整标定板位置 cv2.drawChessboardCorners(left, (9, 6), corners_l, ret_l_found) cv2.drawChessboardCorners(right, (9, 6), corners_r, ret_r_found) cv2.imshow('left', left) cv2.imshow('right', right) if cv2.waitKey(1) == 27: break这段代码不是简单地把图像存下来,而是在采集时就先用cv2.findChessboardCorners确认左右两幅图都能找到同一组角点。只有同时找到才保存,这样标定时的数据质量有保障。参数(9, 6)是棋盘格内角点数量,对应你的标定板实际规格;如果你的板子是10x7,要改成(9, 7)或者(10, 6),注意是内角点不是格子数,这是最容易搞错的地方。
拿到约30对图像后,跑stereoCalibrate。这里我不贴完整代码,直接说关键参数:criteria建议设置(cv2.TERM_CRITERIA_MAX_ITER | cv2.TERM_CRITERIA_EPS, 100, 1e-6),迭代次数给到100次;flags建议用cv2.CALIB_FIX_INTRINSIC先固定单目标定结果,再优化外参,这样左右相机的内参不会跑偏。标定完成后,R,T,E,F分别对应旋转矩阵、平移向量、本质矩阵和基础矩阵。其中平移向量T的三个分量里,最重要的就是T[0],也就是基线距。你的YOLOX检测框能不能算出准的距离,很大程度上取决于这个基线距是否准确。
2.2 立体校正与重投影矩阵Q
标定完只是拿到了几何关系,真正要算深度还得做立体校正(stereo rectification)。校正的目的,是把左右图像通过单应变换重新投影,让左右相机的极线水平对齐,这样同一个目标点在左右图上的坐标只差一个水平方向的视差。你不需要手动实现极线几何,OpenCV的stereoRectify会直接帮你计算左右校正映射矩阵和重投影矩阵Q。
Q矩阵是核心,它是一个4x4矩阵,能把左右图像上的齐次坐标点投影到三维空间:
Q = | 1 0 0 -cx | | 0 1 0 -cy | | 0 0 0 f | | 0 0 -1/Tx (cx - cx')/Tx |上面的cx,cy是左相机主点,f是焦距(像素单位),Tx是基线距。这些值都来自stereoCalibrate结果。stereoRectify之后,你需要用cv2.initUndistortRectifyMap生成左右两幅图的映射表,再通过cv2.remap对输入图像做重映射。这一步做完,左右图像才能用于后续的视差计算。立体校正的验证方法很简单:在左右校正图上画同一条水平线,看标定板的角点是否都落在同一条线上。落不上的话,回头检查标定板采样时是不是有太大倾角或者图像模糊。
校正后的图像还有一个作用:你YOLOX检测框的左图和右图坐标,必须基于校正后的图像坐标系。很多项目图省事,直接在原始图上跑YOLOX,然后在深度图上取值,结果检测框中心和视差计算中心错位,测距值忽远忽近。所以我的习惯是:先对每一帧做remap校正,再送进YOLOX检测器。虽然多一步变换,但数据的坐标系是统一的,后面做点对点映射时才不会踩坑。
3. YOLOX检测框如何映射到深度图:坐标对齐与ROI深度提取
3.1 检测框到深度图的像素映射
双目测距最终要的是深度图,而深度图一般由stereoBM或SGBM算法计算得到。SGBM的视差图质量好但慢,BM快得多但噪声大。我的经验是,如果目标是实时测距,先用cv2.StereoSGBM_create离线验证效果,再替换成BM调参。不管用哪种算法,你得到的都是一个和左图校正后分辨率相同的视差图disparity,深度值计算公式是depth = f * Tx / disparity。
现在关键问题来了:YOLOX输出的检测框坐标是在哪张图上的?如果你跑YOLOX的输入是校正后的左图,那检测框直接对应视差图坐标;如果输入是原始左图,那需要先把原始图校正,再在图上跑YOLOX,或者把检测框坐标做一次映射。最省事、避免坐标系混乱的做法是:把remap之后的左图作为YOLOX的唯一输入,这样检测框的(x1, y1, x2, y2)直接就是视差图上的像素区间,不需要任何变换。下面是一段检测框到深度提取的示意代码:
import cv2 import numpy as np # 假设已经加载YOLOX模型,且使用校正后的 left_rectified # left_rectified: (H, W, 3), disparity: (H, W) float32, Q: 4x4 boxes = yolox_infer(left_rectified) # 返回 N x [x1, y1, x2, y2, cls, conf] for box in boxes: x1, y1, x2, y2, cls, conf = box[:6] # 略去越界索引,同时避免目标太靠近图像边缘导致深度噪声 x1 = max(0, int(x1)); y1 = max(0, int(y1)) x2 = min(disparity.shape[1]-1, int(x2)); y2 = min(disparity.shape[0]-1, int(y2)) # 提取检测框内所有有效视差,剔除<=0和极大值 roi_disp = disparity[y1:y2, x1:x2] valid_disp = roi_disp[(roi_disp > 0) & (roi_disp < 192)] if valid_disp.size == 0: continue # 用中位数而非均值,避免框内个别错误匹配点把距离拉偏 median_disp = np.median(valid_disp) # Q矩阵投影法:只需要视差和像素坐标 homo = np.array([(x1+x2)/2.0, (y1+y2)/2.0, median_disp, 1.0]) point_3d = Q @ homo z_distance = point_3d[2] / point_3d[3] print(f'目标距离: {z_distance:.2f} m')这段代码有两个参数需要你根据实际调。第一,valid_disp的上限192是视差范围,取决于你的相机配置;近距离目标视差大,远距离目标视差小,最大值建议设为分辨率宽度的四分之一左右,太大容易混入噪声。第二,用的是np.median而不是np.mean,因为视差图在物体边缘和低纹理区域会有离群噪点,中位数能压掉这些离群值。如果你的检测框内包含大量背景,见第4章的坑。
3.2 深度值滤波与目标距离计算
一次中位数滤波还不够,因为单帧视差图本身有随机误差。我的做法是对连续N帧(一般取5~10帧)的同目标深度做滑动窗口滤波,用窗口内的中位数作为最终输出。注意“同目标”三个字——跨帧匹配目标时不能用最简单的中心点最近邻,要结合检测框的交并比(IoU)做目标跟踪。比如你先记录上一帧每个目标的框,当前帧每个目标框计算与上一帧所有框的IoU,IoU大于0.5的认为是同一目标,然后把这个目标的深度值放进滑动窗口。这样就能避免突然有个误检框插进来把滤波窗口污染掉。
深度计算时还有一个容易被忽略的点:视差和深度不是线性关系。视差差1个像素,在近距离(比如0.5米)可能对应几厘米的距离误差,但在远距离(比如5米)可能对应几十厘米。所以你不能用固定的单位换算,必须通过Q矩阵投影或者直接用f * Tx / disp公式。如果你的相机经过校正,Q[3][2]就是-1/Tx,投影法算出来的z_distance已经包含了焦距和主点修正,比手工除法更稳。
到这里,你已经能把YOLOX检测到的每个目标输出一个稳定距离。但马上会遇到一堆细节问题:为什么测出来忽远忽近?为什么框越大距离越偏?别急,下一章讲避坑。
4. 常见问题与避坑:标定、对齐、测距误差的5个实战坑
4.1 左右图像分辨率不一致导致检测框偏移
现象:左右相机型号相同,但采集时因为摄像头驱动默认参数不同,左图是640x480,右图是1280x720,校正后视差图和检测框对不上,测距值乱跳。
原因:左右图像分辨率不同,stereoRectify计算映射表时虽然内部会处理,但你的YOLOX如果只跑了左边,视差图按照较大分辨率生成,检测框坐标就不能直接用。
解决:在采集脚本里强制将左右画面设置为相同分辨率,例如cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)和cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480),并且在生成映射表时统一使用同样的尺寸。我一般还会在标定前先打印左右帧的shape确认完全一致,不一致就先修摄像头参数,别急着往下走。
4.2 深度图噪声大,单帧测距不稳定
现象:同一目标静止不动,测出来的距离每秒波动半米,车还停着呢,距离读数却像在跳舞。
原因:视差图本身有匹配噪声,尤其是低纹理区域(比如纯色车漆)、重复纹理(比如百叶窗)。SGBM虽然比BM好,但在光照变化和反射表面依然会出错。
解决:先做深度值的时间滤波,用滑动窗口中位数,窗口长度至少5帧。如果还是跳,检查StereoSGBM_create的参数:numDisparities和blockSize对噪声影响最大。blockSize越大视差越平滑,但会损失细节;numDisparities太小会截断近距离目标的视差。我常用numDisparities=64、blockSize=5起步,再根据你的目标距离范围调整。另外,给深度图做一个cv2.medianBlur核为5的预处理,也能明显减少孤立噪声。
4.3 检测框边缘包含背景,深度值偏大
现象:检测一个人,框把身后的墙也包进来了,测出来的距离比真实距离多出1米多。
原因:YOLOX的检测框是矩形,不可能完美贴合物体轮廓,尤其在目标姿态不规则时,框内会混入大量背景像素。矩形框越大,背景占比越高。
解决:不要直接用整个框的深度中位数。先用灰度直方图或连通域分析把框内前景和背景分开。简单粗暴的做法是对框内深度值做聚类,比如取深度值的直方图,找峰值最多的区间作为前景。更稳的是只取检测框中心区域(比如去掉边缘10%的像素)来计算中位数。我一般用框内所有有效视差的5%到95%分位数区间作为有效范围,再把中位数落在该区间内的像素挑出来重新计算。这个方法能明显修正框内背景干扰。
4.4 标定时棋盘格角度太少,校正后图像扭曲
现象:校正后的左右图像一致性很差,水平线对不齐,视差图边缘出现严重黑色区域。
原因:标定板数据拍摄时只让棋盘格正对着相机,缺少不同角度的倾斜和旋转,导致内参求解病态,畸变系数拟合不准。
解决:重新采集标定数据。保证每对图像中棋盘格至少占画面1/3,并且覆盖画面左右上下四个角落;标定板尽量每次变换姿态,比如左右旋转30度、上下俯仰20度、前后移动不同的距离。一个可用的检查方法:标定完用cv2.calibrateCamera的重投影误差作为指标,误差大于0.5像素就说明数据不够好,重新采集,别省这一步。
4.5 测距结果与真值偏差大,如何排查
现象:用卷尺测真值2米,程序输出2.35米,而且距离越远偏差越大,接近线性增长。
原因:这种线性偏差大概率是基线距Tx不准,或者焦距f不准。如果stereoRectify的Q矩阵是从stereoCalibrate结果算的,那Tx可能是标定得到的优化值而非真实物理基线,偏差被吸收进了标定误差。
解决:用一个已知距离的平板校验,拍10个不同距离的点,测量误差随距离的变化。如果误差随距离线性增加,说明视差计算正确但焦点或基线参数有偏差。此时可以反推:真实距离为Z_true,你计算用的f和Tx,真实误差来自两者的比例。重新标定,并且优先检查标定板的角点精确度,确保每张图的角点都被正确亚像素化。建议用cv2.cornerSubPix对检测到的角点做一步亚像素优化,这一步能显著提升标定精度。
5. 从标定到测距的完整流程:一个可复现的脚本骨架
5.1 离线标定参数保存与加载
上面几章讲了很多细节,现在把它们串成一个完整流程。我的习惯是标定一次性完成,然后把参数存成.npz或.yaml文件,运行时直接加载,避免每次启动都重新标定。文件里保存的内容包括:左右相机内参K_l、K_r,畸变系数D_l、D_r,旋转矩阵R、平移向量T,以及由stereoRectify计算出的Q矩阵。保存代码很简单,但要注意加载时必须用同一分辨率,否则映射表要重新生成。
如下是一个保存参数的示例:
import numpy as np import cv2 # K_l, D_l, K_r, D_r, R, T 来自 stereoCalibrate np.savez('./stereo_params.npz', K_l=K_l, D_l=D_l, K_r=K_r, D_r=D_r, R=R, T=T, Q=Q) # Q 来自 stereoRectify # 运行时加载 with np.load('./stereo_params.npz') as data: K_l = data['K_l']; D_l = data['D_l'] K_r = data['K_r']; D_r = data['D_r'] R = data['R']; T = data['T'] Q = data['Q']加载之后,初始化左右映射表。如果运行时图像尺寸与标定时不同,需要重新调用cv2.stereoRectify和initUndistortRectifyMap。这就是为什么我坚持让摄像头分辨率固定,避免动态切换。把映射表创建放在初始化阶段,不要在每一帧里重复创建,否则性能会大打折扣。
5.2 实时测距主循环
以下是融合了立体校正、SGBM视差、YOLOX检测和深度滤波的实时主循环骨架。这里用伪协议接口替代具体YOLOX推理代码,因为不同部署方式(ONNX、TensorRT、PyTorch)代码差异很大,但逻辑相同。
import cv2 import numpy as np # 省略加载YOLOX模型代码,假设有 infer(left_rectified) 函数 # 省略映射表生成代码,假设已有 map1_l, map2_l, map1_r, map2_r def compute_disparity(left, right): # 转灰度,SGBM输入是单通道 gray_l = cv2.cvtColor(left, cv2.COLOR_BGR2GRAY) gray_r = cv2.cvtColor(right, cv2.COLOR_BGR2GRAY) sgbm = cv2.StereoSGBM_create( minDisparity=0, numDisparities=64, # 必须是16的倍数 blockSize=5, # 奇数,3~11之间 P1=8 * 3 * 5 * 5, P2=32 * 3 * 5 * 5, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=32, ) disp = sgbm.compute(gray_l, gray_r).astype(np.float32) / 16.0 return disp def get_distance(disparity, Q, box): x1, y1, x2, y2 = box[:4] x1, y1 = max(0, int(x1)), max(0, int(y1)) x2 = min(disparity.shape[1] - 1, int(x2)) y2 = min(disparity.shape[0] - 1, int(y2)) roi = disparity[y1:y2, x1:x2] valid = roi[(roi > 0) & (roi < 192)] if valid.size == 0: return -1.0 # 去掉框内深度直方图的极端值,取中位数 median_disp = np.median(valid) point = np.array([(x1 + x2) / 2.0, (y1 + y2) / 2.0, median_disp, 1.0]) point_3d = Q @ point if abs(point_3d[3]) < 1e-6: return -1.0 return point_3d[2] / point_3d[3] while True: ret_l, left_raw = left_cap.read() ret_r, right_raw = right_cap.read() if not (ret_l and ret_r): break # 1. 立体校正,统一坐标系 left = cv2.remap(left_raw, map1_l, map2_l, cv2.INTER_LINEAR) right = cv2.remap(right_raw, map1_r, map2_r, cv2.INTER_LINEAR) # 2. 计算视差图 disparity = compute_disparity(left, right) # 3. YOLOX检测(输入校正后的左图) detections = yolox_infer(left) # 4. 对每个检测框计算距离 for det in detections: dist = get_distance(disparity, Q, det) if dist > 0: x1, y1, x2, y2 = map(int, det[:4]) label = f'{det[4]} {dist:.2f}m' cv2.rectangle(left, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(left, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow('depth', disparity / (192.0 / 255.0)) cv2.imshow('result', left) if cv2.waitKey(1) == 27: break代码中compute_disparity的numDisparities=64表示能计算的视差范围是0到64个像素,根据你的目标距离范围调整:目标越近,视差越大,可能需要128甚至256;目标越远,64就够。blockSize=5是匹配窗口尺寸,越大视差图越平滑,但边缘会变糊,对精准测距来说5到7是比较折中的选择。disp12MaxDiff=1是左右一致性检查的阈值,大于1会引入更多无效视差,建议保持默认。
主循环里我用interpolation=cv2.INTER_LINEAR做重映射,没有用INTER_NEAREST,因为线性插值能让边缘平滑一些,而最近邻插值会加剧视差图的锯齿。如果你追求最高精度,可以考虑INTER_CUBIC,但速度更慢。实际部署时如果发现视差图对检测框的依赖太大,需要先输出中间结果验证——把视差图直接可视化,看目标物体是否明显区分于背景。
6. 把误差压到厘米级:深度滤波、中值窗口与标定板优化技巧
前面已经给了能跑通的基础方案,但工程上真正难的是把误差从半米压到十厘米以内。这里我分享三个亲测有效的技巧,你可以在自己项目里直接套用。
第一个技巧是自适应深度滤波。不要对所有目标用同一个中值窗口大小,而是根据目标的检测框尺寸动态调整。框越大,说明目标占的像素越多,你可以用更大的窗口取深度值;框小的时候,目标像素少,深度值少,如果还用大窗口会把背景拉进来。我的实现是:当框的高度大于100像素时,取框内所有有效视差值的15%~85%分位数之间的像素重新计算中位数;当框高度小于50像素时,直接取全框中位数,但要额外做一个5帧时间滤波。这种按目标大小分级的策略,比单一中位数在混合距离场景下稳定很多。
第二个技巧是针对标定板数据的。很多人的标定误差不是来自算法,而是来自标定板本身。建议打印在硬质铝板或树脂板上,不要用普通A4纸,纸板表面弯曲会引入系统误差。采集时不要只让标定板面对相机,而是让标定板在画面中移动,覆盖边缘区域。同时用cv2.cornerSubPix亚像素细化角点,这一步能让重投影误差下降一半以上。我见过一个项目,标定误差从0.8像素降到0.3像素后,测距误差直接缩小了30%,可见标定这个环节性价比极高。
第三个技巧是使用“锚点校验”。在完成系统搭建后,你需要在现场放三个已知距离的参照物(比如0.5米、1米、3米的纸箱),运行程序记录输出。如果三个点的误差方向一致(都偏大或都偏小),说明是系统性的标定偏差,可以通过调整stereoRectify的alpha参数来优化校正边界,或者直接重新标定。如果误差方向不一致,比如近处偏大、远处偏小,那多半是视差计算参数不适配距离范围,需要调整numDisparities。这个锚点校验法能帮你快速定位是哪一环节出了问题,而不是盲目调参。
最后说一个我的血泪经验:千万别在检测框刚出现的第一帧就给用户显示距离值。YOLOX偶尔会误检,第一帧的深度也可能刚好是噪声峰值。我一开始懒,没做这个延迟,结果检测一个远处路牌时突然跳出一个0.3米的距离,差点被甲方当成系统故障。后来我给每个目标强行设置了连续5帧稳定后才输出距离,之后就没再翻过车。做测距功能,稳定性比单帧精度更重要,用户盯着的是读数稳不稳,不是某一帧的精确值。希望这个经验对你的项目有帮助。
本文还有配套的精品资源,点击获取