ceres学习笔记(三)

学习了example中pose_graph_3d的部分,记录一下学习过程。

前言:

翻译一下readme里面的内容:

...

该示例还说明了如何将 Eigen 的几何模块与 Ceres 的自动微分功能结合使用。 为了表示方向,我们将使用 Eigen 的四元数,它使用 Hamiltonian 约定,但与 Ceres 的旋转表示相比具有不同的元素顺序。 具体来说,它们的区别在于标量分量 q_w 是第一个还是最后一个;  Ceres 的四元数的元素顺序是 [q_w, q_x, q_y, q_z],而 Eigen 的四元数是 [q_x, q_y, q_z, q_w]。

该包定义了对 3 维位姿图优化问题进行建模所需的必要 Ceres 成本函数,以及构建和解决问题的二进制文件。 显示成本函数是为了指导目的,并且可以通过使用需要更长的时间来实现的分析导数来加速。

运行 ---------- 此包包含一个可执行文件 `pose_graph_3d`,它将读取问题定义文件。 此可执行文件可以处理任何使用 g2o 格式的 3D 问题定义,四元数用于方向表示。 为不同格式(如 TORO 或其他格式)实施新阅读器会相对简单。  `pose_graph_3d` 将打印 Ceres 求解器的完整摘要,然后将机器人的原始和优化姿势(分别为 `poses_original.txt` 和 `poses_optimized.txt`)输出到磁盘,格式如下:

pose_id x y z q_x q_y q_z q_w

pose_id x y z q_x q_y q_z q_w

pose_id x y z q_x q_y q_z q_w

其中 `pose_id` 是文件定义中对应的整数 ID。 请注意,文件将按 `pose_id` 的升序排序。
...

主要是对于ceres用于位姿优化的一个说明,对于ceres和eigen关于四元数定义还有costfunction的设置。然后对于g2o文件内容的说明。example里面没有找到g2o文件的data,最后在github里面翻到了,链接如下:

Datasets – Luca Carlone

一、pose_graph_3d_error_term.h部分的解析

pose_graph_3d将代价函数类的定义单独写了一个头文件pose_graph_3d_error_term.h。先对它进行学习。

翻译注释:

计算两个姿势的误差项,这两个姿势之间具有相对姿势测量值。 带hat的变量是测量值。 我们有两个位姿 x_a 和 x_b。 通过传感器测量,我们可以测量帧 B w.r.t 帧 A 的变换,表示为 t_ab_hat。 我们可以计算位姿的当前估计和测量之间的误差度量。
在这个公式中,我们选择将刚性变换表示为哈密顿四元数 q 和位置 p。 四元数顺序为 [x, y, z,w]。

估计的测量值是:t_ab = [ p_ab ] = [ R(q_a)^T * (p_b - p_a) ]

                                                                      [ q_ab ]

                                                                      [ q_a^{-1}* q_b ]

其中 ^{-1} 表示逆矩阵,R(q) 是四元数的旋转矩阵。 现在我们可以计算估计和测量转换之间的误差度量。 对于方向误差,我们将使用标准乘法误差,结果为:

error = [ p_ab - \hat{p}_ab ]

               [ 2.0 * Vec(q_ab * \hat{q}_ab^{-1}) ]

其中 Vec(*) 返回四元数的向量(虚部)部分。 由于测量具有与其准确性相关的不确定性,我们将通过测量信息矩阵的平方根对误差进行加权:

residuals = I^{1/2) * error 其中 I 是信息矩阵,它是协方差的倒数。

下面对PoseGraph3dErrorTerm类进行解析。

 private:
  // The measurement for the position of B relative to A in the A frame.
  const Pose3d t_ab_measured_;
  // The square root of the measurement information matrix.
  const Eigen::Matrix<double, 6, 6> sqrt_information_;

有两个私有变量,

t_ab_measured_表示在A下,B相对于A的位姿。这里要看一下Pose3d的定义:

struct Pose3d {
  Eigen::Vector3d p;
  Eigen::Quaterniond q;

  // The name of the data type in the g2o file format.
  static std::string name() { return "VERTEX_SE3:QUAT"; }

  EIGEN_MAKE_ALIGNED_OPERATOR_NEW
};

它包括一个数据类型是Eigen::Vector3d的p表示位置,一个数据类型是Eigen::Quaterniond的q表示旋转。然后还定义了一个name函数返回VERTEX_SE3:QUAT,是se3下的四元数。

sqrt_information_表示测量信息矩阵的平方根,它的数据类型是double,size是6*6。

然后是构造函数:

  PoseGraph3dErrorTerm(const Pose3d& t_ab_measured,
                       const Eigen::Matrix<double, 6, 6>& sqrt_information)
      : t_ab_measured_(t_ab_measured), sqrt_information_(sqrt_information) {}

它将传入的两个参数分别赋值给两个私有变量。

接下来是关键的重载运算符operator():

  template <typename T>
  bool operator()(const T* const p_a_ptr,
                  const T* const q_a_ptr,
                  const T* const p_b_ptr,
                  const T* const q_b_ptr,
                  T* residuals_ptr) const

传入了5个参数,注意这里全部是相对于世界坐标系下的

p_a_ptr表示a点的位置;

q_a_ptr表示a点的旋转用四元数来表示;

p_b_ptr表示b点的位置;

q_b_ptr表示b点的旋转;

residuals_ptr是最后算的残差。

    Eigen::Map<const Eigen::Matrix<T, 3, 1>> p_a(p_a_ptr);
    Eigen::Map<const Eigen::Quaternion<T>> q_a(q_a_ptr);

    Eigen::Map<const Eigen::Matrix<T, 3, 1>> p_b(p_b_ptr);
    Eigen::Map<const Eigen::Quaternion<T>> q_b(q_b_ptr);

这里使用了eigen里面的映射函数主要是为了数据转换,将p_a_ptr等分别赋值给p_a等,方便之后的运算。

    // Compute the relative transformation between the two frames.
    Eigen::Quaternion<T> q_a_inverse = q_a.conjugate();
    Eigen::Quaternion<T> q_ab_estimated = q_a_inverse * q_b;

计算a和b之间的位姿变换关系,q_a_inverse表示q_a的逆,使用conjugate函数来求。最后求出了q_ab_estimated表示从b变换到a的旋转矩阵。这里就相当于q_b是相对于世界坐标系下的旋转,q_a是相当于世界坐标系下的旋转。然后q_a_inverse * q_b就算出了b在a坐标系下的旋转。

公式推导如上图所示。

    // Represent the displacement between the two frames in the A frame.
    Eigen::Matrix<T, 3, 1> p_ab_estimated = q_a_inverse * (p_b - p_a);

这是求出了从b到a的平移向量p_ab_estimated。同理p_b表示世界坐标系下b的位置,p_a表示世界坐标系下a的位置,然后p_ab_estimated就应该表示b点在a坐标系下平移向量。公式推导如下:

比较清晰明确。

    // Compute the error between the two orientation estimates.
    Eigen::Quaternion<T> delta_q =
        t_ab_measured_.q.template cast<T>() * q_ab_estimated.conjugate();

这部分是计算从b到a的旋转部分的测量值和估计值之间的差,这里运用了四元数的差来求旋转的角度差,这部分没有找到什么资料,在一篇博客里面看到了四元数的差,只给出了公式,但也没有给出推导的过程。

四元数的差、对数、指数、幂以及差值_Magic_Conch_Shell的博客-CSDN博客_四元数的差

    // Compute the residuals.
    // [ position         ]   [ delta_p          ]
    // [ orientation (3x1)] = [ 2 * delta_q(0:2) ]
    Eigen::Map<Eigen::Matrix<T, 6, 1>> residuals(residuals_ptr);
    residuals.template block<3, 1>(0, 0) =
        p_ab_estimated - t_ab_measured_.p.template cast<T>();
    residuals.template block<3, 1>(3, 0) = T(2.0) * delta_q.vec();

这部分就是给6*1的残差向量赋值,前三行是位移部分的差值,后三行是旋转部分的差值,vec是取四元数的虚部,这里不知道为什么乘了2。

    // Scale the residuals by the measurement uncertainty.
    residuals.applyOnTheLeft(sqrt_information_.template cast<T>());

这里是加上了信息矩阵。

  static ceres::CostFunction* Create(
      const Pose3d& t_ab_measured,
      const Eigen::Matrix<double, 6, 6>& sqrt_information) {
    return new ceres::AutoDiffCostFunction<PoseGraph3dErrorTerm, 6, 3, 4, 3, 4>(
        new PoseGraph3dErrorTerm(t_ab_measured, sqrt_information));
  }

这里是创建自动求导的代价函数,是因为四元数不能使用常规方法来求导,就使用了这个。其中

new ceres::AutoDiffCostFunction<PoseGraph3dErrorTerm, 6, 3, 4, 3, 4>

第一个6表示residuals是6维的;

第二个3表示operator()函数哪里的p_a_ptr是3维的,后面的类似。

二、common/read_g2o.h部分

这是一个处理g2o文件的头文件,下面来简单解析一下。

结合一下g20文件的内容来看:

// Reads a single pose from the input and inserts it into the map. Returns false
// if there is a duplicate entry.
template <typename Pose, typename Allocator>
bool ReadVertex(std::ifstream* infile,
                std::map<int, Pose, std::less<int>, Allocator>* poses) {
  int id;
  Pose pose;
  *infile >> id >> pose;

  // Ensure we don't have duplicate poses.
  if (poses->find(id) != poses->end()) {
    LOG(ERROR) << "Duplicate vertex with ID: " << id;
    return false;
  }
  (*poses)[id] = pose;

  return true;
}

阅读顶点部分,通过传入的文件,将分别传入id和pose数据结构里面。这里有一个if判断,判断的内容是查看poses是否有id,这里的处理很巧妙,它希望的是判断是否有重复的位姿,这里的poses应该还没有存入id和pose,存入操作在下面,find()stl函数所以如果发现有相同的就返回iterator值,没有就会返回end()后面的iterator值,所以判断是否等于,如果不等于说明poses里面已经有key为id的数据,说明重复输入了。然后有一个map数据格式的变量poses,将id和pose赋予它。

// Reads the contraints between two vertices in the pose graph
template <typename Constraint, typename Allocator>
void ReadConstraint(std::ifstream* infile,
                    std::vector<Constraint, Allocator>* constraints) {
  Constraint constraint;
  *infile >> constraint;

  constraints->push_back(constraint);
}

阅读两点之间限制的边部分,这里是将g2o中的两点之间限制的边部分传入,具体得看main里面怎么写。

// In 3D, a vertex is defined as follows:
//
// VERTEX_SE3:QUAT ID x y z q_x q_y q_z q_w
//
// where the quaternion is in Hamilton form.
// A constraint is defined as follows:
//
// EDGE_SE3:QUAT ID_a ID_b x_ab y_ab z_ab q_x_ab q_y_ab q_z_ab q_w_ab I_11 I_12 I_13 ... I_16 I_22 I_23 ... I_26 ... I_66 // NOLINT
//
// where I_ij is the (i, j)-th entry of the information matrix for the
// measurement. Only the upper-triangular part is stored. The measurement order
// is the delta position followed by the delta orientation.

这部分注释主要的意思是说明g2o不同行的格式。

开头为VERTEX_SE3:QUAT的表示第一个参数是序号,后面3个是在世界坐标系下的位移值,最后四个是四元数。

开头为EDGE_SE3:QUAT的表示由b到a坐标系下的相关值,

第一个值和第二个值表示从第二个到第一个的变换;

第三个值到第五个值表示从b到a坐标系下位移值的变换;

第六个值到第十个值表示从b到a坐标系下旋转的变换,以四元数表示;

后面的就是信息矩阵的上半部分,因为它是对称的所以存了一半。

template <typename Pose,
          typename Constraint,
          typename MapAllocator,
          typename VectorAllocator>
bool ReadG2oFile(const std::string& filename,
                 std::map<int, Pose, std::less<int>, MapAllocator>* poses,
                 std::vector<Constraint, VectorAllocator>* constraints) 

这是阅读g2o文件,

第一个参数是阅读的文件名;

第二个参数是关于顶点的poses;

第三个参数是关于两点之间的限制,边constraints。

  poses->clear();
  constraints->clear();

  std::ifstream infile(filename.c_str());
  if (!infile) {
    return false;
  }

清空加判断是否有文件输入。

  std::string data_type;
  while (infile.good()) {
    // Read whether the type is a node or a constraint.
    infile >> data_type;
    if (data_type == Pose::name()) {
      if (!ReadVertex(&infile, poses)) {
        return false;
      }
    } else if (data_type == Constraint::name()) {
      ReadConstraint(&infile, constraints);
    } else {
      LOG(ERROR) << "Unknown data type: " << data_type;
      return false;
    }

    // Clear any trailing whitespace from the line.
    infile >> std::ws;
  }

定义string类型data_type,它用来判断读入的是VERTEX_SE3:QUAT还是EDGE_SE3:QUAT来执行ReadVertex函数或者ReadConstraint函数。很妙的设计!

三、type.h部分

这部分主要是对于一些数据结构的定义优化和对于g2o文件读取的设置。

struct Pose3d {
  Eigen::Vector3d p;
  Eigen::Quaterniond q;

  // The name of the data type in the g2o file format.
  static std::string name() { return "VERTEX_SE3:QUAT"; }

  EIGEN_MAKE_ALIGNED_OPERATOR_NEW
};

定义Pose3d的结构,包含一个vector3d的p和四元数q,并且name函数返回VERTEX_SE3:QUAT,正好是g2o文件中顶点开头的字符串。

inline std::istream& operator>>(std::istream& input, Pose3d& pose) {
  input >> pose.p.x() >> pose.p.y() >> pose.p.z() >> pose.q.x() >> pose.q.y() >>
      pose.q.z() >> pose.q.w();
  // Normalize the quaternion to account for precision loss due to
  // serialization.
  pose.q.normalize();
  return input;
}

重写了>>运算符,将左侧数据写入pose里面,并将q正规化。

typedef std::map<int,
                 Pose3d,
                 std::less<int>,
                 Eigen::aligned_allocator<std::pair<const int, Pose3d>>>
    MapOfPoses;

定义了这么一个别名。

// The constraint between two vertices in the pose graph. The constraint is the
// transformation from vertex id_begin to vertex id_end.
struct Constraint3d {
  int id_begin;
  int id_end;

  // The transformation that represents the pose of the end frame E w.r.t. the
  // begin frame B. In other words, it transforms a vector in the E frame to
  // the B frame.
  Pose3d t_be;

  // The inverse of the covariance matrix for the measurement. The order of the
  // entries are x, y, z, delta orientation.
  Eigen::Matrix<double, 6, 6> information;

  // The name of the data type in the g2o file format.
  static std::string name() { return "EDGE_SE3:QUAT"; }

  EIGEN_MAKE_ALIGNED_OPERATOR_NEW
};

定义了Constraint3d的结构,有id_begin和id_end分别表示序号。

t_be表示由end到begin的变换旋转加平移;

information是6*6的信息矩阵;

name函数返回EDGE_SE3:QUAT字符串。

inline std::istream& operator>>(std::istream& input, Constraint3d& constraint) {
  Pose3d& t_be = constraint.t_be;
  input >> constraint.id_begin >> constraint.id_end >> t_be;

  for (int i = 0; i < 6 && input.good(); ++i) {
    for (int j = i; j < 6 && input.good(); ++j) {
      input >> constraint.information(i, j);
      if (i != j) {
        constraint.information(j, i) = constraint.information(i, j);
      }
    }
  }
  return input;
}

重写了>>运算符,并把相对应的量分别输入,注意对于信息矩阵的赋值,因为g2o文件的信息矩阵只存了上三角,所要要这样写。

typedef std::vector<Constraint3d, Eigen::aligned_allocator<Constraint3d>>
    VectorOfConstraints;

另一个别名。

四、pose_graph_3d.cc部分

从main开始看起:

  ceres::examples::MapOfPoses poses;
  ceres::examples::VectorOfConstraints constraints;

前面部分都是一些读参,这两条是运用了上面type.h里面的两个别名。

poses是一个map,key为int类型,value是Pose3d类型的变量;

constraints是一个vector类型,存储为Constraint3d类型的变量。

  CHECK(ceres::examples::ReadG2oFile(FLAGS_input, &poses, &constraints))
      << "Error reading the file: " << FLAGS_input;

  std::cout << "Number of poses: " << poses.size() << '\n';
  std::cout << "Number of constraints: " << constraints.size() << '\n';

  CHECK(ceres::examples::OutputPoses("poses_original.txt", poses))
      << "Error outputting to poses_original.txt";

进入ReadG2oFile函数读取g2o文件,并输出一些原始数据。

  ceres::Problem problem;
  ceres::examples::BuildOptimizationProblem(constraints, &poses, &problem);

设置problem对象,并开始优化的相关操作。

具体看BuildOptimizationProblem函数。

// Constructs the nonlinear least squares optimization problem from the pose
// graph constraints.
void BuildOptimizationProblem(const VectorOfConstraints& constraints,
                              MapOfPoses* poses,
                              ceres::Problem* problem) 

注释的意思是构造通过图限制来构造最小二乘的优化问题。BuildOptimizationProblem函数的第一个参数是constraints表示两点之间的约束的数据结构;

第二个是poses,代表顶点的数据;

第三个就是ceres的problem。

  CHECK(poses != NULL);
  CHECK(problem != NULL);
  if (constraints.empty()) {
    LOG(INFO) << "No constraints, no problem to optimize.";
    return;
  }

判断参数是否为空。

  ceres::LossFunction* loss_function = NULL;
  ceres::LocalParameterization* quaternion_local_parameterization =
      new EigenQuaternionParameterization;

设置loss_function为空指针,说明没有设置核函数;

设置了ceres关于四元数相关计算变量。

  for (VectorOfConstraints::const_iterator constraints_iter =
           constraints.begin();
       constraints_iter != constraints.end();
       ++constraints_iter) {
    const Constraint3d& constraint = *constraints_iter;

    MapOfPoses::iterator pose_begin_iter = poses->find(constraint.id_begin);
    CHECK(pose_begin_iter != poses->end())
        << "Pose with ID: " << constraint.id_begin << " not found.";
    MapOfPoses::iterator pose_end_iter = poses->find(constraint.id_end);
    CHECK(pose_end_iter != poses->end())
        << "Pose with ID: " << constraint.id_end << " not found.";

    const Eigen::Matrix<double, 6, 6> sqrt_information =
        constraint.information.llt().matrixL();
    // Ceres will take ownership of the pointer.
    ceres::CostFunction* cost_function =
        PoseGraph3dErrorTerm::Create(constraint.t_be, sqrt_information);

    problem->AddResidualBlock(cost_function,
                              loss_function,
                              pose_begin_iter->second.p.data(),
                              pose_begin_iter->second.q.coeffs().data(),
                              pose_end_iter->second.p.data(),
                              pose_end_iter->second.q.coeffs().data());

    problem->SetParameterization(pose_begin_iter->second.q.coeffs().data(),
                                 quaternion_local_parameterization);
    problem->SetParameterization(pose_end_iter->second.q.coeffs().data(),
                                 quaternion_local_parameterization);
  }

在这个for循环中完成对于边限制数据的读取计算。下面进行详细的分析:

  for (VectorOfConstraints::const_iterator constraints_iter =
           constraints.begin();
       constraints_iter != constraints.end();
       ++constraints_iter)

因为是vector容器,所以可以使用迭代器。

    const Constraint3d& constraint = *constraints_iter;

    MapOfPoses::iterator pose_begin_iter = poses->find(constraint.id_begin);
    CHECK(pose_begin_iter != poses->end())
        << "Pose with ID: " << constraint.id_begin << " not found.";
    MapOfPoses::iterator pose_end_iter = poses->find(constraint.id_end);
    CHECK(pose_end_iter != poses->end())
        << "Pose with ID: " << constraint.id_end << " not found.";

取出begin_id和end_id,就是数据的前两个数值,分别赋值给pose_begin_iter和pose_end_iter。注意它是用find函数在poses里面取。

    const Eigen::Matrix<double, 6, 6> sqrt_information =
        constraint.information.llt().matrixL();

将信息矩阵进行llt分解并赋值给sqrt_information矩阵,Eigen的LLT分解实现了Cholesky 分解。

Eigen的LLT分解_火星机器人life的博客-CSDN博客_eigen llt

    // Ceres will take ownership of the pointer.
    ceres::CostFunction* cost_function =
        PoseGraph3dErrorTerm::Create(constraint.t_be, sqrt_information);

通过Create构造函数生成cost_function。

    problem->AddResidualBlock(cost_function,
                              loss_function,
                              pose_begin_iter->second.p.data(),
                              pose_begin_iter->second.q.coeffs().data(),
                              pose_end_iter->second.p.data(),
                              pose_end_iter->second.q.coeffs().data());

这里是proble对象的AddResidualBlock模块,

第一个参数cost_function是设定的代价函数;

第二个参数loss_function是核函数,没有定义;

第三个参数pose_begin_iter->second.p.data()是第一个点的位置变量;

第四个参数pose_begin_iter->second.q.coeffs().data()是第一个点的旋转变量;coeffs()是xyzw的顺序;

后面两个参数类似。

    problem->SetParameterization(pose_begin_iter->second.q.coeffs().data(),
                                 quaternion_local_parameterization);
    problem->SetParameterization(pose_end_iter->second.q.coeffs().data(),
                                 quaternion_local_parameterization);

这里的意思是说,四元数不符合常规运算的,eigen专门为它配置了运算,需要在problem配置时加入。

位姿图优化问题有六个未完全约束的自由度。 这通常称为规范自由度。 
您可以对所有节点应用刚体变换,优化问题仍将具有完全相同的成本。  
Levenberg-Marquardt 算法具有可缓解此问题的内部阻尼,但最好适当地约束规范自由度。 
这可以通过将其中一个姿势设置为常量来实现,这样优化器就无法更改它。

大概意思是第一个点固定?

  MapOfPoses::iterator pose_start_iter = poses->begin();
  CHECK(pose_start_iter != poses->end()) << "There are no poses.";
  problem->SetParameterBlockConstant(pose_start_iter->second.p.data());
  problem->SetParameterBlockConstant(pose_start_iter->second.q.coeffs().data());

取出poses的第一个赋值给迭代器pose_start_iter,然后将它的p和q都在problem中设置成恒定值,就是上面那段翻译的意思。

再回到main里面来。

  CHECK(ceres::examples::SolveOptimizationProblem(&problem))
      << "The solve was not successful, exiting.";

开始优化,就是ceres求导的solve环节。

// Returns true if the solve was successful.
bool SolveOptimizationProblem(ceres::Problem* problem) {
  CHECK(problem != NULL);

  ceres::Solver::Options options;
  options.max_num_iterations = 200;
  options.linear_solver_type = ceres::SPARSE_NORMAL_CHOLESKY;

  ceres::Solver::Summary summary;
  ceres::Solve(options, problem, &summary);

  std::cout << summary.FullReport() << '\n';

  return summary.IsSolutionUsable();
}

设置solver的options,最大迭代次数设置为200次;

采用ceres::SPARSE_NORMAL_CHOLESKY稀疏且可夫斯基模式求解?

然后开始solve,并输入summary的信息,最后返回是否成功标识。

  CHECK(ceres::examples::OutputPoses("poses_optimized.txt", poses))
      << "Error outputting to poses_original.txt";

最后将优化后的位姿输出到poses_optimized.txt文件中去。

结束,终于算是理解的比较透彻的了。

五、最终运行

在经过我一系列的修改后,终于成功的跑起来了。

以下是输出结果:

Number of poses: 1661
Number of constraints: 6275

Solver Summary (v 2.0.0-eigen-(3.4.0)-lapack-suitesparse-(5.1.2)-cxsparse-(3.1.9)-eigensparse-no_openmp)

                                     Original                  Reduced
Parameter blocks                         3322                     3320
Parameters                              11627                    11620
Effective parameters                     9966                     9960
Residual blocks                          6275                     6275
Residuals                               37650                    37650

Minimizer                        TRUST_REGION

Sparse linear algebra library    SUITE_SPARSE
Trust region strategy     LEVENBERG_MARQUARDT

                                        Given                     Used
Linear solver          SPARSE_NORMAL_CHOLESKY   SPARSE_NORMAL_CHOLESKY
Threads                                     1                        1
Linear solver ordering              AUTOMATIC                     3320

Cost:
Initial                          8.362723e+03
Final                            6.341883e-01
Change                           8.362088e+03

Minimizer iterations                       20
Successful steps                           20
Unsuccessful steps                          0

Time (in seconds):
Preprocessor                         0.004724

  Residual only evaluation           0.597028 (20)
  Jacobian & residual evaluation    18.880399 (20)
  Linear solver                      0.425803 (20)
Minimizer                           19.939410

Postprocessor                        0.000370
Total                               19.944509

Termination:                      CONVERGENCE (Function tolerance reached. |cost_change|/cost: 6.104042e-07 <= 1.000000e-06)

看一下生成的两个txt文件:

 使用python可视化显示:

 好像效果不明显,换个sphere看看。

 这就明显多了。CMakelists.txt内容比较简单,就不写了。

时间2023年1月17日23点06分,到这个时间为止,没有发现有比我对这个源码注释的最详细的。hhh,啦啦啦

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值