1. 项目概述:从零构建双目视觉的“尺子”
最近在做一个嵌入式项目,需要在资源受限的设备上实现一个简单的测距功能。市面上的方案要么太贵,要么依赖OpenCV这类“重型”库,对硬件要求不低。于是,我决定自己动手,用纯C++实现一套双目视觉的三角测量核心算法,完全绕开OpenCV。这听起来有点“造轮子”,但当你真正理解从两张二维图片中计算出三维坐标的每一步时,那种对原理的掌控感,是调用现成API无法比拟的。这个项目非常适合想深入理解计算机视觉几何基础的朋友,或者需要在没有OpenCV环境的嵌入式、工控场景下实现三维感知的开发者。整个过程,就像是用最基础的数学工具,亲手打造一把测量三维空间的“尺子”。
2. 核心原理拆解:三角测量到底在算什么?
在开始写代码之前,我们必须把双目视觉三角测量的数学原理吃透。很多人一上来就找代码,结果参数调来调去都不对,根本原因是对原理一知半解。
2.1 从人眼到相机:视差如何产生深度
我们人类能用双眼判断距离,核心在于“视差”。当你交替闭上一只眼睛看同一个物体时,会发现它在背景上的位置发生了偏移。大脑就是根据这个偏移量(视差)来估算距离的。双目相机系统模仿了这一过程。
假设我们有两个完全相同的相机,它们的成像平面在同一平面上,且光轴平行——这就是经典的双目平行光轴模型。一个三维空间点P,在左相机成像平面上的投影点是$p_l (x_l, y)$,在右相机上的投影点是$p_r (x_r, y)$。注意,因为在理想平行配置下,两个相机的Y坐标是相同的。那么,视差$d$的定义就是:$d = x_l - x_r$。
这里有一个关键点:对于相机前方的物体,它在右相机图像中的位置,会比在左相机图像中更靠左(假设左相机是基准)。因此,$x_r < x_l$,视差$d$为正值。物体越近,这个位置差异(视差)就越大;物体越远,差异越小,直至趋近于零。
2.2 三角测量公式推导:从像素到三维坐标
知道了视差,如何换算成真实的三维坐标呢?这需要用到相似三角形的几何关系。
假设两个相机的焦距都是$f$,它们的光心距离(基线长度)为$B$。根据上图(在脑海中构建或画出来)的相似三角形关系,我们可以得到: $ \frac{Z}{f} = \frac{B}{d} $ 其中,$Z$就是我们要求的深度(物体到相机平面的垂直距离)。
由此,我们得到了最核心的深度计算公式: $ Z = \frac{f \cdot B}{d} $
得到深度Z后,三维空间点P的X和Y坐标也可以根据小孔成像模型反推出来: $ X = \frac{x_l \cdot Z}{f} $ $ Y = \frac{y \cdot Z}{f} $ 这里我们通常以左相机的光心为世界坐标系原点。
注意:这个公式成立的前提是前面提到的理想平行光轴模型,并且$x_l, y$是已经转换到以图像物理中心(即光轴与成像平面交点)为原点的坐标系下的坐标,而不是图像左上角为(0,0)的像素坐标。这是我们后续代码中必须进行的一步关键坐标转换。
2.3 为何选择不用OpenCV?
这可能是很多人的第一个疑问。OpenCV已经提供了cv::triangulatePoints这样成熟的函数,为什么还要自己实现?
- 依赖与部署:OpenCV是一个庞大的库。在嵌入式设备(如ARM Cortex-M系列、某些边缘计算盒子)或要求极简依赖的工业软件中,引入整个OpenCV可能不现实或代价高昂。纯C++实现的核心算法,编译后可能只有几十KB。
- 理解与掌控:调用库函数是一个黑盒。当三角测量结果出现系统性偏差时,你很难定位问题是出在校准、匹配还是三角计算本身。自己实现一遍,对每一个参数(如焦距、主点、基线)的物理意义和影响都会有刻骨铭心的理解。
- 定制与优化:你的应用场景可能非常特殊。例如,只需要计算特定区域的深度,或者需要对三角测量过程加入特殊的约束或滤波。自己写的代码可以随心所欲地修改和优化。
- 学习价值:这是学习计算机视觉多视图几何不可逾越的一步。自己推导并实现,知识才会变成你自己的。
3. 系统设计与关键模块
一个完整的、不依赖OpenCV的双目三角测量系统,可以分解为以下几个核心模块。我们将按照数据处理的流水线来设计。
3.1 模块一:相机模型与参数管理
这是整个系统的基石。我们需要一个数据结构来精确描述相机的内部参数(内参)和两个相机之间的相对位置关系(外参)。
内参(Intrinsics):描述相机自身的成像几何。
- 焦距(fx, fy):以像素为单位。由于制造工艺,x和y方向的焦距可能有微小差异。
- 主点(cx, cy):图像物理中心(光轴与成像平面交点)的像素坐标。
- 畸变系数(k1, k2, p1, p2, k3...):描述镜头导致的图像形变。这是最复杂的部分,但在我们追求简洁和速度的初始版本中,可以假设输入的图像已经过畸变校正。这是一个重要的简化,但非常实用。在实际项目中,校正可以在上位机用OpenCV完成,然后将校正后的图像和相机内参(校正后的)下发给我们的C++程序。
外参(Extrinsics):描述右相机相对于左相机的位姿。
- 旋转矩阵R(3x3):理想平行模型中,R是单位矩阵。
- 平移向量T(3x1):其中$T[0]$就是基线长度B(以米为单位)。在平行模型中,T只有X分量有值,即$T = [B, 0, 0]^T$。
在代码中,我们可以设计一个Camera类来管理这些参数。
// 示例:一个简化的相机参数结构体 struct CameraIntrinsics { double fx, fy; // 焦距 (像素) double cx, cy; // 主点 (像素) // 暂不考虑畸变 }; struct CameraExtrinsics { // 假设我们已经将右相机图像旋转校正到与左相机平行 // 因此旋转矩阵R是单位阵,我们只关心平移向量T double baseline; // 基线长度 (米),对应 T[0] // 更一般的情况,可以存储完整的3x1平移向量 T double Tx, Ty, Tz; }; class StereoCameraSystem { public: StereoCameraSystem(const CameraIntrinsics& leftIntrinsics, const CameraIntrinsics& rightIntrinsics, const CameraExtrinsics& extrinsics); // 核心三角测量函数 bool triangulate(double xl, double yl, // 左图像匹配点像素坐标 double xr, double yr, // 右图像匹配点像素坐标 double& X, double& Y, double& Z) const; // 输出世界坐标(左相机坐标系) private: CameraIntrinsics leftCam_, rightCam_; CameraExtrinsics extrinsics_; // 内参矩阵的逆,预先计算以提高效率 Eigen::Matrix3d leftK_inv_, rightK_inv_; };实操心得:即使目标是“不用OpenCV”,在开发调试阶段,强烈建议用OpenCV或Matlab等工具先对相机进行高精度标定,获取准确的内外参。将这些参数作为常量硬编码或配置文件读入你的纯C++程序。不要试图自己写标定算法,那是一个更复杂的领域。我们的目标是“使用”标定结果,而不是“实现”标定过程。
3.2 模块二:图像匹配与视差计算
这是双目视觉中最具挑战性的一环。给定左图的一个点,如何在右图中找到它的对应点?对于平行光轴模型,由于极线是水平的,我们只需要在右图的同一水平行(即相同的y坐标)上进行搜索即可,这大大简化了问题。这就是所谓的“极线校正”的目标。
匹配算法选择: 对于自己实现的轻量级系统,块匹配(Block Matching)是一个很好的起点。
- SAD(绝对误差和):计算简单,速度快。
cost = Σ|I_left(x+i, y+j) - I_right(x-d+i, y+j)|,其中d是待测试的视差。 - SSD(误差平方和):对差异更敏感,但计算量稍大。
- ZNCC(零均值归一化互相关):对光照变化具有较好的鲁棒性,但计算最复杂。
在我们的C++实现中,可以从SAD开始。我们需要设定一个搜索范围(minDisparity到maxDisparity)和一个匹配窗口大小(如5x5, 7x7)。
// 简化的SAD块匹配函数示例 int computeDisparitySAD(const uint8_t* leftImg, const uint8_t* rightImg, int imgWidth, int imgHeight, int x, int y, int windowRadius, int minDisp, int maxDisp) { int bestDisparity = 0; int minCost = INT_MAX; for (int d = minDisp; d <= maxDisp; ++d) { if (x - windowRadius - d < 0 || x + windowRadius >= imgWidth) { continue; // 边界检查,确保窗口在图像内 } int cost = 0; for (int j = -windowRadius; j <= windowRadius; ++j) { for (int i = -windowRadius; i <= windowRadius; ++i) { int idxLeft = (y + j) * imgWidth + (x + i); int idxRight = (y + j) * imgWidth + (x + i - d); // 注意:x - d cost += std::abs(leftImg[idxLeft] - rightImg[idxRight]); } } if (cost < minCost) { minCost = cost; bestDisparity = d; } } return bestDisparity; }视差图:对左图的每一个像素(或感兴趣区域的像素)执行上述匹配,得到的结果就是一个与左图同尺寸的“视差图”(Disparity Map),每个像素值代表该点的视差d。
踩坑记录:匹配窗口大小的选择是精度和速度的权衡。窗口太小,对噪声敏感,匹配不稳定;窗口太大,在物体边缘处会模糊,导致深度图边界不清晰。通常从7x7开始调试。另外,必须进行严格的边界处理,否则程序极易在图像边缘崩溃。
3.3 模块三:三角测量核心算法实现
这是将我们推导的数学公式转化为代码的关键一步。输入是匹配好的像素坐标(xl, yl)和(xr, yr),以及相机参数,输出是三维点(X, Y, Z)。
根据之前的推导,在理想平行模型下,步骤是:
- 检查视差:
d = xl - xr。如果d <= 0,说明匹配可能出错(物体应在无穷远或匹配错误),返回无效值。 - 将像素坐标转换到归一化相机坐标系(去除内参影响): $ x_{norm_l} = (x_l - cx) / fx $ $ y_{norm_l} = (y_l - cy) / fy $ (对于右图同理,但通常我们假设校正后左右相机内参一致,且y坐标相同)
- 计算深度Z:$ Z = (f_x * B) / d $。注意,这里用
fx和以像素为单位的视差d计算,B是物理基线长度(米)。f_x * B这个乘积需要根据你的标定结果来定。 - 计算三维坐标: $ X = x_{norm_l} * Z $ $ Y = y_{norm_l} * Z $
代码实现如下:
bool StereoCameraSystem::triangulate(double xl, double yl, double xr, double yr, double& X, double& Y, double& Z) const { // 1. 计算视差 double disparity = xl - xr; // 假设xr是校正后右图对应点的x坐标 if (disparity <= 0.0) { // 视差非正,点位于无穷远或匹配无效 return false; } // 2. 像素坐标归一化 (左相机) double xn_l = (xl - leftCam_.cx) / leftCam_.fx; double yn_l = (yl - leftCam_.cy) / leftCam_.fy; // 3. 计算深度Z (基于平行模型简化公式) // 注意:这里的 baseline 是 extrinsics_.Tx,单位是米 // fx * baseline 是一个常数,可以预先计算 Z = (leftCam_.fx * extrinsics_.baseline) / disparity; // 4. 计算三维坐标 (左相机坐标系) X = xn_l * Z; Y = yn_l * Z; return true; }关键细节:公式中的
f和d必须单位一致。如果焦距fx是以像素为单位,那么视差d也必须是以像素为单位的差值。基线B通常以米为单位,那么计算出的Z也是以米为单位。确保单位统一是避免结果差几个数量级的关键。
3.4 模块四:后处理与优化
直接由匹配和三角测量得到的三维点云通常噪声很大,需要后处理。
视差滤波:
- 唯一性约束:在匹配时,除了从左到右匹配,也可以从右到左匹配。只有两者匹配结果一致的点才认为是可靠的。
- 峰值比滤波:检查匹配代价函数的最小值与次小值的比值。如果比值太小(如<1.5),说明匹配模糊,拒绝该点。
- 一致性检查:相邻像素的视差不应出现剧烈跳变,可以用中值滤波或双边滤波对视差图进行平滑。
深度滤波:
- 范围过滤:根据应用场景,设定一个有效的深度范围
[Z_min, Z_max],剔除过近或过远的无效点。 - 统计滤波:计算局部邻域内深度的均值μ和标准差σ,剔除那些深度值不在
[μ-ασ, μ+ασ]范围内的离群点。这对于去除飞点非常有效。
- 范围过滤:根据应用场景,设定一个有效的深度范围
// 简单的深度中值滤波示例 void medianFilterDepthMap(std::vector<double>& depthMap, int width, int height, int kernelRadius) { std::vector<double> window; window.reserve((2*kernelRadius+1)*(2*kernelRadius+1)); std::vector<double> filtered = depthMap; for (int y = kernelRadius; y < height - kernelRadius; ++y) { for (int x = kernelRadius; x < width - kernelRadius; ++x) { window.clear(); for (int j = -kernelRadius; j <= kernelRadius; ++j) { for (int i = -kernelRadius; i <= kernelRadius; ++i) { int idx = (y + j) * width + (x + i); if (depthMap[idx] > 0) { // 只考虑有效的深度点 window.push_back(depthMap[idx]); } } } if (!window.empty()) { std::sort(window.begin(), window.end()); filtered[y * width + x] = window[window.size() / 2]; // 取中值 } } } depthMap.swap(filtered); }4. 完整代码实现与流程串联
现在,我们将所有模块串联起来,形成一个完整的处理流程。假设我们有两张已经过畸变校正和极线校正的灰度图像leftImg和rightImg。
// 主处理流程伪代码 int main() { // 1. 加载相机参数 (从文件或硬编码) CameraIntrinsics intri; // 假设左右相机内参相同且已校正 intri.fx = 800.0; intri.fy = 800.0; intri.cx = 320.0; intri.cy = 240.0; // 假设图像640x480 CameraExtrinsics extri; extri.baseline = 0.12; // 12厘米基线 StereoCameraSystem stereoSystem(intri, intri, extri); // 2. 加载图像 (这里需要自己实现或使用其他轻量库,如stb_image) int width = 640, height = 480; std::vector<uint8_t> leftImg = loadGrayImage("left.png", width, height); std::vector<uint8_t> rightImg = loadGrayImage("right.png", width, height); // 3. 参数设置 int windowRadius = 3; // 7x7窗口 int minDisparity = 5; int maxDisparity = 80; // 根据场景和基线估算 // 4. 计算视差图 std::vector<int> disparityMap(width * height, 0); for (int y = windowRadius; y < height - windowRadius; ++y) { for (int x = windowRadius + maxDisparity; x < width - windowRadius; ++x) { int d = computeDisparitySAD(leftImg.data(), rightImg.data(), width, height, x, y, windowRadius, minDisparity, maxDisparity); disparityMap[y * width + x] = d; } } // 5. (可选)对视差图进行滤波 // medianFilterDisparity(disparityMap, width, height, 2); // 6. 三角测量,生成点云 std::vector<Point3d> pointCloud; for (int y = 0; y < height; ++y) { for (int x = 0; x < width; ++x) { int idx = y * width + x; int d = disparityMap[idx]; if (d > 0) { double xr = x - d; // 平行校正下,右图对应点x坐标 double X, Y, Z; if (stereoSystem.triangulate(x, y, xr, y, X, Y, Z)) { if (Z > 0.3 && Z < 5.0) { // 深度范围过滤,例如0.3米到5米 pointCloud.emplace_back(X, Y, Z); } } } } } // 7. 保存点云 (例如为PLY格式,便于用MeshLab等查看) savePointCloudToPLY("output.ply", pointCloud); std::cout << "三角测量完成,生成 " << pointCloud.size() << " 个三维点。" << std::endl; return 0; }性能提示:上述双循环遍历每个像素计算视差,是计算最密集的部分,时间复杂度为O(widthheightdisparityRange*windowArea)。在实际应用中,可以考虑以下优化:1) 使用积分图像加速SAD计算;2) 只对图像中感兴趣的区域(如通过边缘检测得到)进行计算;3) 使用多线程并行处理不同的图像行;4) 对于嵌入式平台,可以考虑将搜索范围
[minDisp, maxDisp]用固定点整数运算来模拟浮点数,并利用SIMD指令集。
5. 常见问题、调试技巧与效果评估
自己实现算法,调试是最大的挑战。以下是几个常见问题及排查思路。
5.1 深度值全部异常(过大或过小)
- 检查相机参数单位:这是最常见的问题。确认
fx, fy, cx, cy的单位是像素。基线B的单位是米。如果你的标定结果中基线是以毫米为单位的(例如120mm),那么代码中需要转换为0.12米。 - 检查视差正负:确保你的匹配算法得到的视差
d是x_left - x_right,并且对于前方的物体,d应该是正数。如果反过来,深度Z会变成负值。 - 验证坐标转换:在
triangulate函数中,打印中间变量。xn_l和yn_l应该在0附近小范围波动(例如[-1, 1])。如果值非常大,说明像素坐标减去主点(cx,cy)或除以焦距(fx,fy)的步骤有误。
5.2 深度图充满噪声和“空洞”
- 匹配窗口大小:尝试增大匹配窗口(如从5x5到9x9)。更大的窗口能抑制噪声,但会损失边缘细节。
- 视差搜索范围:
minDisparity和maxDisparity设置是否正确?它们定义了最近和最远物体的搜索范围。可以用一个已知距离的物体来反推大致的视差:d ≈ fx * B / Z_known。 - 图像质量:输入图像是否模糊、光照不均或缺乏纹理?双目匹配在纹理丰富的区域效果才好。对于白墙、纯色桌面等弱纹理区域,匹配必然失败,产生噪声或空洞。这是立体视觉的固有难题。
- 后处理滤波:务必加入中值滤波、一致性检查等后处理步骤。原始的视差图通常是不能直接用的。
5.3 重建物体形状扭曲或缩放不对
- 极线校正是否完美?我们的整个推导基于完美的平行光轴模型。如果实际的双目图像没有经过严格的极线校正,那么匹配点对就不满足
y_left == y_right的假设,导致三角测量公式失效。确保输入图像是经过立体校正的。这一步可以用OpenCV的stereoRectify和initUndistortRectifyMap完成,然后将校正映射表应用到图像上,这个步骤可以离线进行。 - 相机参数不准:内参(特别是焦距
fx,fy)和外参(基线B)的标定误差会直接导致深度和三维坐标的系统性误差。重新进行高精度标定。
5.4 如何验证你的代码是正确的?
- 构造合成数据:这是最可靠的调试方法。在三维软件(如Blender)中创建几个已知三维坐标的虚拟点,用虚拟的双目相机渲染出左右两张图像。将图像和已知的、精确的相机参数输入你的程序。计算出的三维点应该与原始点几乎重合。你可以控制所有变量,完美隔离算法问题。
- 使用标准数据集:使用Middlebury、KITTI等权威立体视觉数据集。它们提供了校正后的图像、标定参数和真实的视差图/深度图。你可以用它们的图像和参数运行你的代码,将生成的视差图与真实值对比。
- 分模块测试:单独测试
triangulate函数。手动输入几组已知的、正确的匹配点对(可以通过特征点匹配算法如SIFT获得,但需确保匹配准确),看输出的三维坐标是否合理。
5.5 效果评估指标
对于视差图/深度图,常用的定量评估指标有:
- 误匹配率(Bad Pixel Ratio):计算视差图与真实值(Ground Truth)差异超过一定阈值(如1像素或2像素)的像素比例。
- 均方根误差(RMSE):视差或深度误差的均方根。
- 平均绝对误差(MAE):误差绝对值的平均值。
对于自己实现的项目,初期更应关注定性评估:重建出的点云是否大致反映了物体的形状?平面物体是否重建为平面?不同距离的物体是否有正确的深度层次?
6. 从原理到进阶:还能做些什么?
当你成功运行起这个基础版本后,可以沿着以下方向深化,打造一个更鲁棒、更高效的系统。
- 更优的匹配算法:用半全局匹配(SGM)替代简单的局部块匹配。SGM通过在多条路径上进行动态规划,能显著改善弱纹理区域和遮挡区域的匹配效果。虽然实现比SAD复杂,但有很多开源的精简实现可以参考,其核心思想是代价聚合。
- 亚像素精度视差:我们目前计算的视差是整数像素。通过在对匹配代价函数曲线(在最佳视差附近)进行二次或线性插值,可以获得亚像素精度的视差,从而将深度精度提升一个数量级。
- 处理非平行配置:如果相机无法被校正到理想平行(比如基线不水平),我们需要使用更一般的三角测量方法。这通常涉及求解一个最小二乘问题:从两个相机的投影矩阵$P_l$和$P_r$出发,对于匹配点对$p_l$和$p_r$,求解三维点$P$,使得重投影误差$||p_l - P_l P||^2 + ||p_r - P_r P||^2$最小。这可以通过线性三角测量(DLT)或迭代优化的方法求解。
- 集成到实时系统:将算法移植到嵌入式平台(如树莓派、Jetson Nano、STM32+OV系列摄像头模组)。重点优化内存使用和计算速度,可能需要对图像进行降采样,使用定点数运算,甚至手写ARM NEON汇编进行加速。
- 添加彩色信息:将RGB图像的色彩信息附加到生成的三维点云上,可以得到彩色的点云,视觉效果和后续应用价值(如分割、识别)会更好。
实现一个不依赖OpenCV的双目三角测量系统,就像完成了一次计算机视觉的“底层修炼”。你获得的不仅仅是一个可用的测距代码,更是对多视图几何、相机模型、优化算法的深刻理解。当你在项目中遇到奇怪的3D重建问题时,这份从底层构建的经验会让你更快地定位到问题的根源——是标定不准,是匹配出错,还是三角测量公式用错了?这种解决问题的能力,远比单纯会调用一个库函数要宝贵得多。