从零实现双目视觉三角测量:C++手写算法替代OpenCV
2026/7/12 3:13:00 网站建设 项目流程

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这样成熟的函数,为什么还要自己实现?

  1. 依赖与部署:OpenCV是一个庞大的库。在嵌入式设备(如ARM Cortex-M系列、某些边缘计算盒子)或要求极简依赖的工业软件中,引入整个OpenCV可能不现实或代价高昂。纯C++实现的核心算法,编译后可能只有几十KB。
  2. 理解与掌控:调用库函数是一个黑盒。当三角测量结果出现系统性偏差时,你很难定位问题是出在校准、匹配还是三角计算本身。自己实现一遍,对每一个参数(如焦距、主点、基线)的物理意义和影响都会有刻骨铭心的理解。
  3. 定制与优化:你的应用场景可能非常特殊。例如,只需要计算特定区域的深度,或者需要对三角测量过程加入特殊的约束或滤波。自己写的代码可以随心所欲地修改和优化。
  4. 学习价值:这是学习计算机视觉多视图几何不可逾越的一步。自己推导并实现,知识才会变成你自己的。

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)是一个很好的起点。

  1. SAD(绝对误差和):计算简单,速度快。cost = Σ|I_left(x+i, y+j) - I_right(x-d+i, y+j)|,其中d是待测试的视差。
  2. SSD(误差平方和):对差异更敏感,但计算量稍大。
  3. ZNCC(零均值归一化互相关):对光照变化具有较好的鲁棒性,但计算最复杂。

在我们的C++实现中,可以从SAD开始。我们需要设定一个搜索范围minDisparitymaxDisparity)和一个匹配窗口大小(如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)

根据之前的推导,在理想平行模型下,步骤是:

  1. 检查视差:d = xl - xr。如果d <= 0,说明匹配可能出错(物体应在无穷远或匹配错误),返回无效值。
  2. 将像素坐标转换到归一化相机坐标系(去除内参影响): $ x_{norm_l} = (x_l - cx) / fx $ $ y_{norm_l} = (y_l - cy) / fy $ (对于右图同理,但通常我们假设校正后左右相机内参一致,且y坐标相同)
  3. 计算深度Z:$ Z = (f_x * B) / d $。注意,这里用fx和以像素为单位的视差d计算,B是物理基线长度(米)。f_x * B这个乘积需要根据你的标定结果来定。
  4. 计算三维坐标: $ 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; }

关键细节:公式中的fd必须单位一致。如果焦距fx是以像素为单位,那么视差d也必须是以像素为单位的差值。基线B通常以米为单位,那么计算出的Z也是以米为单位。确保单位统一是避免结果差几个数量级的关键。

3.4 模块四:后处理与优化

直接由匹配和三角测量得到的三维点云通常噪声很大,需要后处理。

  1. 视差滤波

    • 唯一性约束:在匹配时,除了从左到右匹配,也可以从右到左匹配。只有两者匹配结果一致的点才认为是可靠的。
    • 峰值比滤波:检查匹配代价函数的最小值与次小值的比值。如果比值太小(如<1.5),说明匹配模糊,拒绝该点。
    • 一致性检查:相邻像素的视差不应出现剧烈跳变,可以用中值滤波或双边滤波对视差图进行平滑。
  2. 深度滤波

    • 范围过滤:根据应用场景,设定一个有效的深度范围[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. 完整代码实现与流程串联

现在,我们将所有模块串联起来,形成一个完整的处理流程。假设我们有两张已经过畸变校正和极线校正的灰度图像leftImgrightImg

// 主处理流程伪代码 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米。
  • 检查视差正负:确保你的匹配算法得到的视差dx_left - x_right,并且对于前方的物体,d应该是正数。如果反过来,深度Z会变成负值。
  • 验证坐标转换:在triangulate函数中,打印中间变量。xn_lyn_l应该在0附近小范围波动(例如[-1, 1])。如果值非常大,说明像素坐标减去主点(cx,cy)或除以焦距(fx,fy)的步骤有误。

5.2 深度图充满噪声和“空洞”

  • 匹配窗口大小:尝试增大匹配窗口(如从5x5到9x9)。更大的窗口能抑制噪声,但会损失边缘细节。
  • 视差搜索范围minDisparitymaxDisparity设置是否正确?它们定义了最近和最远物体的搜索范围。可以用一个已知距离的物体来反推大致的视差:d ≈ fx * B / Z_known
  • 图像质量:输入图像是否模糊、光照不均或缺乏纹理?双目匹配在纹理丰富的区域效果才好。对于白墙、纯色桌面等弱纹理区域,匹配必然失败,产生噪声或空洞。这是立体视觉的固有难题。
  • 后处理滤波:务必加入中值滤波、一致性检查等后处理步骤。原始的视差图通常是不能直接用的。

5.3 重建物体形状扭曲或缩放不对

  • 极线校正是否完美?我们的整个推导基于完美的平行光轴模型。如果实际的双目图像没有经过严格的极线校正,那么匹配点对就不满足y_left == y_right的假设,导致三角测量公式失效。确保输入图像是经过立体校正的。这一步可以用OpenCV的stereoRectifyinitUndistortRectifyMap完成,然后将校正映射表应用到图像上,这个步骤可以离线进行。
  • 相机参数不准:内参(特别是焦距fx,fy)和外参(基线B)的标定误差会直接导致深度和三维坐标的系统性误差。重新进行高精度标定。

5.4 如何验证你的代码是正确的?

  1. 构造合成数据:这是最可靠的调试方法。在三维软件(如Blender)中创建几个已知三维坐标的虚拟点,用虚拟的双目相机渲染出左右两张图像。将图像和已知的、精确的相机参数输入你的程序。计算出的三维点应该与原始点几乎重合。你可以控制所有变量,完美隔离算法问题。
  2. 使用标准数据集:使用Middlebury、KITTI等权威立体视觉数据集。它们提供了校正后的图像、标定参数和真实的视差图/深度图。你可以用它们的图像和参数运行你的代码,将生成的视差图与真实值对比。
  3. 分模块测试:单独测试triangulate函数。手动输入几组已知的、正确的匹配点对(可以通过特征点匹配算法如SIFT获得,但需确保匹配准确),看输出的三维坐标是否合理。

5.5 效果评估指标

对于视差图/深度图,常用的定量评估指标有:

  • 误匹配率(Bad Pixel Ratio):计算视差图与真实值(Ground Truth)差异超过一定阈值(如1像素或2像素)的像素比例。
  • 均方根误差(RMSE):视差或深度误差的均方根。
  • 平均绝对误差(MAE):误差绝对值的平均值。

对于自己实现的项目,初期更应关注定性评估:重建出的点云是否大致反映了物体的形状?平面物体是否重建为平面?不同距离的物体是否有正确的深度层次?

6. 从原理到进阶:还能做些什么?

当你成功运行起这个基础版本后,可以沿着以下方向深化,打造一个更鲁棒、更高效的系统。

  1. 更优的匹配算法:用半全局匹配(SGM)替代简单的局部块匹配。SGM通过在多条路径上进行动态规划,能显著改善弱纹理区域和遮挡区域的匹配效果。虽然实现比SAD复杂,但有很多开源的精简实现可以参考,其核心思想是代价聚合。
  2. 亚像素精度视差:我们目前计算的视差是整数像素。通过在对匹配代价函数曲线(在最佳视差附近)进行二次或线性插值,可以获得亚像素精度的视差,从而将深度精度提升一个数量级。
  3. 处理非平行配置:如果相机无法被校正到理想平行(比如基线不水平),我们需要使用更一般的三角测量方法。这通常涉及求解一个最小二乘问题:从两个相机的投影矩阵$P_l$和$P_r$出发,对于匹配点对$p_l$和$p_r$,求解三维点$P$,使得重投影误差$||p_l - P_l P||^2 + ||p_r - P_r P||^2$最小。这可以通过线性三角测量(DLT)迭代优化的方法求解。
  4. 集成到实时系统:将算法移植到嵌入式平台(如树莓派、Jetson Nano、STM32+OV系列摄像头模组)。重点优化内存使用和计算速度,可能需要对图像进行降采样,使用定点数运算,甚至手写ARM NEON汇编进行加速。
  5. 添加彩色信息:将RGB图像的色彩信息附加到生成的三维点云上,可以得到彩色的点云,视觉效果和后续应用价值(如分割、识别)会更好。

实现一个不依赖OpenCV的双目三角测量系统,就像完成了一次计算机视觉的“底层修炼”。你获得的不仅仅是一个可用的测距代码,更是对多视图几何、相机模型、优化算法的深刻理解。当你在项目中遇到奇怪的3D重建问题时,这份从底层构建的经验会让你更快地定位到问题的根源——是标定不准,是匹配出错,还是三角测量公式用错了?这种解决问题的能力,远比单纯会调用一个库函数要宝贵得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询