设计网站做的工作步骤是,项目管理师国家职业资格证书,卡一卡二卡四老狼,坪地网站建设价格1. 初识Ceres Solver#xff1a;你的非线性优化“瑞士军刀” 如果你正在计算机视觉、机器人或者任何需要处理大量数据拟合的领域里摸爬滚打#xff0c;那你大概率遇到过这样的问题#xff1a;手头有一堆观测数据和一个复杂的数学模型#xff0c;怎么才能找到一组参数#…1. 初识Ceres Solver你的非线性优化“瑞士军刀”如果你正在计算机视觉、机器人或者任何需要处理大量数据拟合的领域里摸爬滚打那你大概率遇到过这样的问题手头有一堆观测数据和一个复杂的数学模型怎么才能找到一组参数让模型最好地“贴合”这些数据呢比如你想用一个指数函数y e^(m*x c)去拟合一组带噪声的传感器数据或者想从几十张不同角度的照片里精确计算出相机的姿态和三维点的位置这就是著名的Bundle Adjustment问题。这类问题在数学上通常被归结为非线性最小二乘问题。这时候Ceres Solver就该登场了。简单来说它是一个由谷歌开发并维护的开源C库专门用来解决这类“让模型拟合数据”的优化问题。你可以把它想象成一把功能强大的“瑞士军刀”无论是简单的曲线拟合还是涉及成千上万个参数的大规模三维重建它都能帮你高效、稳定地找到最优解。我最早接触Ceres是在做SLAM同步定位与地图构建项目的时候当时被它简洁的API和强大的性能深深吸引从此就成了我工具箱里的常客。Ceres这个名字其实很有来头它源于天文学。19世纪初高斯利用最小二乘法仅凭少量观测数据就成功预测了谷神星Ceres被太阳遮挡后的重现位置。这个事件是科学计算史上的一个里程碑Ceres Solver以此命名也寓意着其解决复杂优化问题的雄心。那么Ceres到底能解决什么样的问题呢它的核心是处理以下两类带边界约束的非线性最小二乘问题这是它的老本行形式化表达就是最小化一系列残差项的平方和同时允许你对优化变量设置上下限。比如你估计的相机焦距物理上不可能为负数就可以通过边界约束来保证。一般的无约束优化问题虽然名字叫“最小二乘求解器”但通过巧妙的建模它也能处理更广泛的优化目标。在接下来的内容里我不会一上来就抛出一堆复杂的数学公式吓跑你。相反我会带你从最简单的“Hello World”例子开始手把手教你如何安装、配置并一步步深入到曲线拟合、鲁棒估计乃至Bundle Adjustment等高级应用。你会发现用Ceres解决一个优化问题就像搭积木一样直观定义代价函数、构建问题、配置求解器、运行。咱们先从环境搭建开始。2. 环境搭建与第一个程序Hello, World!在开始写代码之前我们得先把Ceres Solver请到我们的电脑里。Ceres是一个跨平台的库支持Linux、macOS和Windows。我个人最推荐在Ubuntu这类Linux系统上使用因为依赖管理最方便。这里我以Ubuntu 20.04为例演示最快捷的安装方法。2.1 安装依赖与Ceres打开终端依次执行以下命令。这些命令会安装Ceres必需的依赖库比如用于矩阵运算的Eigen、用于日志输出的glog以及一些可选的但很有用的库如SuiteSparse用于稀疏矩阵运算。# 更新软件包列表 sudo apt-get update # 安装编译工具和基础依赖 sudo apt-get install -y cmake libgoogle-glog-dev libgflags-dev # 安装Eigen3线性代数库 sudo apt-get install -y libeigen3-dev # 安装SuiteSparse稀疏矩阵库非必须但推荐 sudo apt-get install -y libsuitesparse-dev接下来我们从GitHub上克隆Ceres的源代码并编译安装。我习惯在~/workspace目录下操作你可以换成任何你喜欢的路径。# 进入工作目录 cd ~/workspace # 克隆Ceres的代码仓库 git clone https://github.com/ceres-solver/ceres-solver.git cd ceres-solver # 创建一个独立的构建目录 mkdir build cd build # 运行CMake生成构建文件。这里开启测试和样例的编译方便学习。 cmake .. -DBUILD_TESTINGON -DBUILD_EXAMPLESON # 开始编译-j8表示用8个线程并行加速根据你的CPU核心数调整 make -j8 # 运行测试确保编译正确 ctest # 安装到系统目录可选安装后其他项目更方便调用 sudo make install如果一切顺利你就成功安装了Ceres。现在让我们来写第一个程序感受一下它的工作流程。这个例子非常简单寻找一个标量x使得残差f(x) 10 - x的平方和最小。显然当x10时残差为0达到最优。2.2 编写你的第一个Ceres程序创建一个名为helloworld.cc的文件输入以下代码。我会逐段为你解释。#include iostream #include ceres/ceres.h // 第一步定义代价函数Cost Function的结构体 // 在Ceres中我们通常通过重载()运算符来定义残差如何计算 struct CostFunctor { // 这是一个模板函数T可以是double或Jet类型用于自动求导 template typename T bool operator()(const T* const x, T* residual) const { // 残差 10.0 - x residual[0] T(10.0) - x[0]; return true; } }; int main(int argc, char** argv) { google::InitGoogleLogging(argv[0]); // 初始化Glog日志库 // 待优化的变量并赋予初始值。优化就是从初始值开始寻找更好的值。 double initial_x 5.0; double x initial_x; // 第二步构建优化问题Problem ceres::Problem problem; // 创建代价函数。这里使用了自动求导AutoDiffCostFunction。 // 模板参数CostFunctor, 1, 1的含义是 // CostFunctor: 我们刚刚定义的结构体类型 // 第一个1: 残差的维度输出维度这里残差是一个标量所以是1 // 第二个1: 输入参数块的维度这里x是一个标量所以是1 ceres::CostFunction* cost_function new ceres::AutoDiffCostFunctionCostFunctor, 1, 1(new CostFunctor); // 向问题中添加残差块ResidualBlock。 // 参数分别是代价函数、损失函数这里为空指针nullptr表示不使用、待优化变量x的指针 problem.AddResidualBlock(cost_function, nullptr, x); // 第三步配置求解器选项并求解 ceres::Solver::Options options; options.linear_solver_type ceres::DENSE_QR; // 对于小规模问题使用稠密QR分解 options.minimizer_progress_to_stdout true; // 将优化过程输出到控制台 ceres::Solver::Summary summary; // 用于存储求解过程的摘要信息 ceres::Solve(options, problem, summary); // 开始求解 // 输出简要报告和结果 std::cout summary.BriefReport() \n; std::cout x : initial_x - x \n; return 0; }2.3 编译与运行将上述代码保存后我们使用CMake来编译它。创建一个CMakeLists.txt文件cmake_minimum_required(VERSION 3.10) project(ceres_hello) find_package(Ceres REQUIRED) include_directories(${CERES_INCLUDE_DIRS}) add_executable(helloworld helloworld.cc) target_link_libraries(helloworld ${CERES_LIBRARIES})然后在终端中执行mkdir build cd build cmake .. make ./helloworld你应该能看到类似下面的输出它展示了优化器迭代的过程iter cost cost_change |gradient| |step| tr_ratio tr_radius ls_iter iter_time total_time 0 1.250000e01 0.00e00 5.00e00 0.00e00 0.00e00 1.00e04 0 4.69e-05 1.08e-04 1 1.249750e-07 1.25e01 5.00e-04 5.00e00 1.00e00 3.00e04 1 1.91e-05 1.40e-04 2 1.388518e-16 1.25e-07 1.67e-08 5.00e-04 1.00e00 9.00e04 1 1.19e-05 1.58e-04 Ceres Solver Report: Iterations: 2, Initial cost: 1.250000e01, Final cost: 1.388518e-16, Termination: CONVERGENCE x : 5 - 10看优化器只用了2次迭代就将x从初始值5优化到了10最终代价残差平方和接近完美的0。这个简单的例子揭示了Ceres工作的核心三步曲定义代价函数、构建问题、配置求解。虽然问题简单但框架和解决一个包含成千上万个参数的SLAM问题是一模一样的。3. 理解核心三种求导方式详解在优化过程中求解器不仅需要计算代价函数的值更需要知道它的梯度或者说雅可比矩阵才能知道该往哪个方向调整参数。Ceres提供了三种计算导数的方式自动求导、数值求导和解析求导。选择哪种方式是效率、便利性和精度之间的权衡。让我用一个具体的例子来帮你理解它们的区别和用法。假设我们有一个稍微复杂一点的代价函数f(x) (10 - x)^2。它的解析导数很容易求是f(x) -2*(10 - x)。但我们假装不知道看看Ceres如何用不同方法处理。3.1 自动求导省心又高效的首选自动求导是Ceres的“明星功能”也是我最推荐新手使用的方式。它的原理利用了C模板元编程在编译期自动推导出导数的计算过程。你只需要像写普通函数一样写出残差的计算式Ceres就能自动为你计算导数。struct AutoDiffCostFunctor { template typename T bool operator()(const T* const x, T* residual) const { // 直接写出残差计算式 residual[0] T(10.0) - x[0]; // 如果是平方也可以写成 residual[0] (T(10.0) - x[0]) * (T(10.0) - x[0]); // 但通常我们让Ceres自己处理平方和 return true; } }; // 使用时 ceres::CostFunction* cost_function new ceres::AutoDiffCostFunctionAutoDiffCostFunctor, 1, 1(new AutoDiffCostFunctor);优点代码简洁不易出错效率通常接近手动推导的解析解。缺点要求残差计算必须能用C模板表达。如果你的残差计算调用了某些无法进行模板特化的外部库函数比如某些黑盒的仿真器自动求导就无能为力了。我的经验在90%的情况下自动求导都是最佳选择。它极大地降低了使用门槛让你能快速将想法转化为可运行的优化代码。3.2 数值求导应对“黑盒”函数的备选方案当你的残差计算是一个“黑盒”函数比如调用了一个MATLAB引擎或者某个没有源代码的库自动求导无法工作。这时数值求导就派上用场了。它通过计算函数值的差分来近似导数最常见的是中心差分法。struct NumericDiffCostFunctor { // 注意这里不是模板函数参数类型固定为double bool operator()(const double* const x, double* residual) const { // 残差计算这里可以调用任何外部函数 residual[0] 10.0 - x[0]; return true; } }; // 使用时需要指定求导方法如CENTRAL ceres::CostFunction* cost_function new ceres::NumericDiffCostFunctionNumericDiffCostFunctor, ceres::CENTRAL, 1, 1(new NumericDiffCostFunctor);优点通用性极强几乎可以处理任何函数。缺点计算量大每计算一次梯度需要调用多次残差函数中心差分法是两次。精度问题差分步长的选择是个技术活步长太大精度低步长太小受浮点数误差影响。收敛慢通常比自动求导需要更多的迭代次数。使用建议除非迫不得已否则不要作为首选。如果必须使用可以尝试ceres::RIDDERS方法它比CENTRAL更精确但也更慢。3.3 解析求导极致性能的终极武器如果你对性能有极致要求并且数学功底扎实可以手动推导出导数的解析形式并直接提供给Ceres。这需要继承ceres::SizedCostFunction类。class QuadraticCostFunction : public ceres::SizedCostFunction1, 1 { public: virtual ~QuadraticCostFunction() {} virtual bool Evaluate(double const* const* parameters, double* residuals, double** jacobians) const { // 1. 计算残差 const double x parameters[0][0]; residuals[0] 10 - x; // 2. 如果请求了雅可比矩阵jacobians不为空则计算并填充 if (jacobians ! nullptr jacobians[0] ! nullptr) { // 这里 f(x) 10 - x 导数是 -1 jacobians[0][0] -1; } return true; } }; // 使用时 ceres::CostFunction* cost_function new QuadraticCostFunction;优点计算速度最快没有额外的模板或差分开销。缺点实现最复杂容易出错尤其是对于多变量、多残差的复杂函数手动推导雅可比矩阵是一项繁琐且易错的工作。我的踩坑经历早期我为了提升一个BA光束法平差问题的速度尝试手动推导雅可比矩阵。结果因为一个正负号错误调试了整整两天。除非你非常确定自己的推导正确无误并且性能瓶颈确实在求导上否则建议优先使用自动求导。总结一下对于初学者和大多数应用无脑选择自动求导。它是安全、高效且代码简洁的完美平衡点。当遇到无法模板化的外部调用时考虑数值求导。只有在你成为高手并且性能分析明确指向求导是瓶颈时才去挑战解析求导。4. 实战进阶从曲线拟合到鲁棒估计掌握了基本用法和求导方式后我们来解决一个更贴近实际的问题曲线拟合。这是科学和工程中非常常见的任务比如根据传感器数据标定模型参数。我们用一个指数衰减模型作为例子y exp(m * x c)。我们有一组带噪声的观测数据(x_i, y_i)目标是找到最优的参数m和c。4.1 基础曲线拟合首先我们定义残差。对于第i个数据点观测值是y_i模型预测值是exp(m * x_i c)所以残差就是两者的差r_i y_i - exp(m * x_i c)。我们的目标是最小化所有残差的平方和。struct ExponentialResidual { ExponentialResidual(double x, double y) : x_(x), y_(y) {} // 构造函数传入数据点 template typename T bool operator()(const T* const m, const T* const c, T* residual) const { // 残差 观测值y - 模型预测值 exp(m*x c) residual[0] T(y_) - exp(m[0] * T(x_) c[0]); return true; } private: const double x_; // 存储x数据 const double y_; // 存储y数据 };构建问题和求解的代码结构与Hello World类似关键区别在于我们需要为每一个数据点创建一个残差块。// 假设数据存储在数组 data[] 中每两个元素一组 (x, y) double m 0.0; // 参数初始值 double c 0.0; ceres::Problem problem; for (int i 0; i kNumObservations; i) { ceres::CostFunction* cost_function new ceres::AutoDiffCostFunctionExponentialResidual, 1, 1, 1( new ExponentialResidual(data[2*i], data[2*i 1])); problem.AddResidualBlock(cost_function, nullptr, m, c); } // ... 配置求解器并求解运行后你会得到优化后的m和c。将它们代回模型就能得到一条最拟合数据的曲线。这个过程直观地展示了如何将实际问题“翻译”成Ceres能理解的形式每个数据点对应一个残差项所有残差项的平方和就是我们要最小化的总目标。4.2 引入鲁棒核函数让拟合更“坚强”现实中的数据往往不完美可能存在离群值。比如在视觉SLAM中错误的特征点匹配或者在传感器数据中偶尔出现的脉冲干扰。这些离群值会严重扭曲最小二乘的结果因为最小二乘对大的残差给予非常大的惩罚平方项导致优化器为了迎合少数异常点而牺牲整体拟合效果。为了解决这个问题Ceres引入了损失函数。损失函数的作用是对残差进行“稳健化”处理降低大残差可能来自离群值对整体目标的影响。这就像给优化过程戴上了一副“智能眼镜”能自动忽略那些明显不合理的噪声。使用起来非常简单只需要在AddResidualBlock时将第二个参数之前是nullptr替换成一个损失函数对象即可。Ceres内置了多种损失函数最常用的是CauchyLoss柯西损失和HuberLoss胡伯损失。// 使用Cauchy损失函数参数0.5控制了损失函数的尺度。 // 这个值越小对离群值的抑制越强。 problem.AddResidualBlock(cost_function, new ceres::CauchyLoss(0.5), // 这里是关键 m, c);为了让你直观感受区别我做过一个对比实验用同一组数据其中故意混入了20%的严重离群值。分别使用普通最小二乘无损失函数和带Cauchy损失的鲁棒拟合。结果非常明显普通拟合的曲线被离群值“拉偏”了而鲁棒拟合的曲线几乎完全不受影响紧紧跟随着正常数据点的趋势。如何选择损失函数和参数HuberLoss: 对中小残差是二次惩罚对大残差是线性惩罚。像一个“温和的警察”参数δ是二次和线性区域的分界点。我通常从delta1.0开始尝试。CauchyLoss: 对于大残差其惩罚会趋于一个常数。像一个“严厉的法官”能更彻底地抑制离群值。参数a控制着“多大算离群”通常需要根据你数据的噪声水平来调。经验之谈如果你的数据噪声模型已知比如服从高斯分布可以尝试TrivialLoss即无损失或HuberLoss。如果怀疑有离群值CauchyLoss效果通常更好。参数 tuning 没有银弹最好在真实数据的一个子集上做交叉验证。5. 挑战复杂问题Bundle Adjustment与性能调优当你熟悉了单变量和曲线拟合后我们可以挑战Ceres的“王牌”应用场景Bundle Adjustment。BA是计算机视觉和多视图几何中的核心优化问题用于同时优化三维点云和相机参数姿态、内参是SLAM和三维重建系统达到高精度的关键一步。一个中等规模的BA问题就可能涉及成千上万个相机参数和数百万个三维点形成海量的优化变量。5.1 Bundle Adjustment问题建模在BA中每个残差项对应一个观测即一个三维点在某一个相机图像上的二维投影位置。假设三维点P世界坐标系下通过相机投影模型比如针孔模型加畸变投影到图像上得到预测的像素坐标(u_pred, v_pred)。我们实际观测到的像素坐标是(u_obs, v_obs)。那么残差就是这两者的差r [u_obs - u_pred, v_obs - v_pred]^T我们的目标就是调整所有相机参数和三维点坐标使得所有观测的残差平方和最小。在Ceres中实现一个完整的BA需要定义复杂的代价函数涉及旋转矩阵通常用四元数或旋转向量表示、相机内参和畸变参数。这里我给出一个高度简化的框架展示其核心思想// 一个简化的BA代价函数假设理想针孔相机无畸变 struct SnavelyReprojectionError { SnavelyReprojectionError(double observed_x, double observed_y) : observed_x(observed_x), observed_y(observed_y) {} template typename T bool operator()(const T* const camera_rotation, // 相机旋转如四元数[4] const T* const camera_translation, // 相机平移[3] const T* const camera_intrinsics, // 相机内参如焦距、主点[4] const T* const point_3d, // 三维点坐标[3] T* residuals) const { // 1. 将三维点从世界坐标系变换到相机坐标系: P_cam R * P_world t T p[3]; // ... 这里需要实现旋转和平移变换可能用到四元数乘法 // 2. 投影到归一化平面: p_normalized (X/Z, Y/Z) T xp p[0] / p[2]; T yp p[1] / p[2]; // 3. 应用内参模型得到预测像素坐标 // 简化模型: u f * xp cx, v f * yp cy T predicted_x camera_intrinsics[0] * xp camera_intrinsics[2]; T predicted_y camera_intrinsics[1] * yp camera_intrinsics[3]; // 4. 计算残差 residuals[0] predicted_x - T(observed_x); residuals[1] predicted_y - T(observed_y); return true; } private: double observed_x; double observed_y; };在实际项目中比如使用COLMAP或OpenMVG的数据你需要构建一个庞大的Problem为每一对相机三维点观测添加一个残差块。虽然代码结构类似但问题的规模和数据管理会复杂得多。5.2 求解器配置与性能调优当问题规模变大后默认的求解器配置可能效率低下甚至无法求解。这时就需要对Solver::Options进行精细调优。下面是我在解决大规模BA问题时常用的一套配置思路ceres::Solver::Options options; options.linear_solver_type ceres::SPARSE_SCHUR; // 关键BA问题具有特殊的稀疏结构SCHUR消元是最高效的。 // options.linear_solver_type ceres::DENSE_SCHUR; // 如果问题规模较小相机几百也可以用稠密版 // 对于SPARSE_SCHUR需要指定稀疏线性代数库 options.sparse_linear_algebra_library_type ceres::SUITE_SPARSE; // 首选性能最好 // options.sparse_linear_algebra_library_type ceres::EIGEN_SPARSE; // 备选无需额外安装 // 预处理子选择对于ITERATIVE_SCHUR求解器至关重要 options.preconditioner_type ceres::SCHUR_JACOBI; // options.preconditioner_type ceres::CLUSTER_JACOBI; // 对于大规模问题聚类预处理子可能更好 // 优化过程输出 options.minimizer_progress_to_stdout true; options.max_num_iterations 100; // 最大迭代次数 options.function_tolerance 1e-6; // 函数值变化容忍度作为收敛条件之一 options.gradient_tolerance 1e-10; // 梯度容忍度 options.parameter_tolerance 1e-8; // 参数变化容忍度 // 线程数设置充分利用多核CPU options.num_threads std::thread::hardware_concurrency(); options.num_linear_solver_threads options.num_threads;关键参数解读与避坑指南linear_solver_type: 这是最重要的参数。对于BA这类问题其海塞矩阵具有天然的稀疏块结构相机-点之间的连接是稀疏的。SPARSE_SCHUR求解器利用舒尔补Schur Complement技术能极大地减少计算量和内存消耗。千万不要对大规模BA使用DENSE_QR或DENSE_NORMAL_CHOLESKY那会立刻导致内存爆炸。sparse_linear_algebra_library_type: 如果你安装了SuiteSparselibsuitesparse-dev强烈建议使用SUITE_SPARSE它的稀疏Cholesky分解速度远超Eigen。如果安装有困难EIGEN_SPARSE是内置的备选方案。preconditioner_type: 如果你使用ITERATIVE_SCHUR求解器对于超大规模问题迭代法比直接法更有内存优势预处理子的选择就非常关键。SCHUR_JACOBI是一个不错的默认选择。收敛条件function_tolerance,gradient_tolerance,parameter_tolerance共同决定了何时停止迭代。通常先保持默认如果优化过早停止或一直不收敛再适当调整。多线程设置num_threads可以显著加速雅可比矩阵的计算。确保你的代价函数是线程安全的即没有修改共享的全局状态。我曾经处理过一个来自公开数据集的中等规模BA问题有50个相机和2万个三维点。使用默认的DENSE_QR求解器程序运行了10分钟内存就溢出了。切换到SPARSE_SCHUR并启用SUITE_SPARSE后同样的问题在20秒内就收敛了内存使用量不到之前的十分之一。这个经历让我深刻体会到选择合适的求解器比盲目优化代码细节重要得多。最后别忘了利用Solver::Summary对象。在求解结束后打印summary.FullReport()里面包含了详细的迭代过程、时间剖析和最终状态。这是你诊断问题性能瓶颈、判断是否收敛的终极工具。通过不断实践和调整这些“旋钮”你会逐渐培养出对非线性优化问题的直觉能够更自信地运用Ceres Solver这把利器去解决工程中遇到的各种挑战。