RANSAC平面拟合理论和代码—PCL源码笔记

RANSAC平面拟合的原理,首先知道如何定义平面,求平面的方程,求平面的法向量,以及求点到平面的距离。 其次,需要了解RANSAC的原理和公式。

一、平面相关定义

我们知道 拟合平面输出平面法向量 python实现 平面拟合原理_算法平面方程的定义。 我们知道,三个点是可以形成一个平面的,因此如果给定三个点,假设拟合平面输出平面法向量 python实现 平面拟合原理_拟合_02, 求这三个点所形成的平面,可根据以下步骤:

  1. 求两点之间的向量 拟合平面输出平面法向量 python实现 平面拟合原理_拟合_03
    拟合平面输出平面法向量 python实现 平面拟合原理_线性代数_04
  2. 求两向量的向量积,即拟合平面输出平面法向量 python实现 平面拟合原理_拟合_05
    拟合平面输出平面法向量 python实现 平面拟合原理_算法_06
  3. 所得向量积即为平面方程拟合平面输出平面法向量 python实现 平面拟合原理_平面_07法向量
    拟合平面输出平面法向量 python实现 平面拟合原理_线性代数_08
  4. 点到平面的距离
    先来看一下向量在轴上的投影:
    设A,B 两点拟合平面输出平面法向量 python实现 平面拟合原理_平面_09 轴上的投影分别为拟合平面输出平面法向量 python实现 平面拟合原理_线性代数_10, 则向量拟合平面输出平面法向量 python实现 平面拟合原理_线性代数_11的长度拟合平面输出平面法向量 python实现 平面拟合原理_算法_12成为向量拟合平面输出平面法向量 python实现 平面拟合原理_线性代数_13拟合平面输出平面法向量 python实现 平面拟合原理_平面_09轴上的投影,记为拟合平面输出平面法向量 python实现 平面拟合原理_拟合_15。设拟合平面输出平面法向量 python实现 平面拟合原理_线性代数_16。设拟合平面输出平面法向量 python实现 平面拟合原理_线性代数_13拟合平面输出平面法向量 python实现 平面拟合原理_平面_09轴之间夹角为拟合平面输出平面法向量 python实现 平面拟合原理_平面_19, 则
    拟合平面输出平面法向量 python实现 平面拟合原理_平面_20
    点到平面的距离计算
    向量法计算点到平面的距离,就是把点和平面放在直角坐标系下进行计算。这样,点和平面均可用坐标来表示(如图1所示)。

假设平面方程为:
拟合平面输出平面法向量 python实现 平面拟合原理_拟合_21

设向量拟合平面输出平面法向量 python实现 平面拟合原理_平面_22 为 平面的法向量,平面外一点拟合平面输出平面法向量 python实现 平面拟合原理_拟合_23,坐标为拟合平面输出平面法向量 python实现 平面拟合原理_拟合_24, 在平面上取一点拟合平面输出平面法向量 python实现 平面拟合原理_平面_25,则点拟合平面输出平面法向量 python实现 平面拟合原理_拟合_23到平面的距离拟合平面输出平面法向量 python实现 平面拟合原理_拟合_27为:

拟合平面输出平面法向量 python实现 平面拟合原理_算法_28

其中拟合平面输出平面法向量 python实现 平面拟合原理_平面_19为向量拟合平面输出平面法向量 python实现 平面拟合原理_线性代数_30与向量拟合平面输出平面法向量 python实现 平面拟合原理_算法_31的夹角,

拟合平面输出平面法向量 python实现 平面拟合原理_拟合_32

因此,
拟合平面输出平面法向量 python实现 平面拟合原理_拟合_33

二、RANSAC原理

理解完平面的定义,法向量求解和点到平面的距离,我们就可以继续探讨RANSAC拟合平面的关键算法: RANSAC算法。

RANSAC算法有以下步骤:

  • 随机选取三个点,根据平面方程拟合平面输出平面法向量 python实现 平面拟合原理_算法_34, 计算模型参数。
  • 用剩余的点计算到该平面方程的距离,将距离与所设定的阈值做比较,如果小于,则该点为内点;否则为外点。统计该参数模型下内点的个数。
  • 继续执行上面两步,若当前模型的内点数量大于已经保存的最大内点数量,则变更新的模型参数,保留模型参数始终是内点数量最多的模型参数。
  • 重复上述三步,不断迭代,直到达到迭代阈值,找到内点个数最多的模型参数,最后用内点再次对模型参数进行估计,从而得到最终的模型参数。

上面有个阈值需要关注,迭代阈值。 我们如何知道迭代的次数呢?其实,这个值是可以估算出来的:

假设内点在数据中的占比为拟合平面输出平面法向量 python实现 平面拟合原理_平面_35,那么每次计算模型使用拟合平面输出平面法向量 python实现 平面拟合原理_算法_36个点的情况下,选取点为内点的概率为拟合平面输出平面法向量 python实现 平面拟合原理_拟合_37,选取的点为外点的概率为拟合平面输出平面法向量 python实现 平面拟合原理_线性代数_38, 因此尝试拟合平面输出平面法向量 python实现 平面拟合原理_拟合_39次失败的概率为:
拟合平面输出平面法向量 python实现 平面拟合原理_线性代数_40
都为内点的概率为:
拟合平面输出平面法向量 python实现 平面拟合原理_算法_41

通过上面的式子,得到最终需要尝试的次数为:
拟合平面输出平面法向量 python实现 平面拟合原理_线性代数_42

但是,通常一开始我们无法知道内点在数据中的占比拟合平面输出平面法向量 python实现 平面拟合原理_拟合_43这个先验,因此可以使用自适应迭代次数的方法。也就是一开始设定一个无穷大的迭代次数,然后每次更新模型参数估计的时候,用当前的内点比值当成拟合平面输出平面法向量 python实现 平面拟合原理_平面_35来估算出迭代次数。 又或者设置一个最大的迭代数,但是往往可能无法达到最优解。

三、PCL平面拟合

PCL官方使用RANSAC你和平面的模板。

pcl::ModelCoefficients::Ptr coefficients (new pcl::ModelCoefficients);
pcl::PointIndices::Ptr inliers (new pcl::PointIndices);
// Create the segmentation object
pcl::SACSegmentation<pcl::PointXYZ> seg;
seg.setModelType (pcl::SACMODEL_PLANE);
seg.setMethodType (pcl::SAC_RANSAC);
seg.setDistanceThreshold (0.01);

seg.setInputCloud (cloud);
seg.segment (*inliers, *coefficients);

步骤如下:

1. 定义平面模型参数,和内点索引。
2. 声明分割模型,并设置模型的类别PLANE,拟合模型的方法RANSAC,距离阈值0.01。
3. 设置输入,并获得结果。

1. segment函数

主要关注一下segment这个模板函数,在PCL源码中的sac_segmentation.hpp文件里。下面进行了精简,方便理解。

template <typename PointT> void
pcl::SACSegmentation<PointT>::segment (PointIndices &inliers, ModelCoefficients &model_coefficients){

    // Copy the header information
    inliers.header = model_coefficients.header = input_->header;
    ... 

    // 计算模型
    if (!sac_->computeModel (0))
    {
        PCL_ERROR ("[pcl::%s::segment] Error segmenting the model! No solution found.\n", getClassName ().c_str ());
        deinitCompute ();
        inliers.indices.clear (); model_coefficients.values.clear ();
        return;
    }

    // 获取内点
    sac_->getInliers (inliers.indices);

    // 获取模型参数
    Eigen::VectorXf coeff (model_->getModelSize ());
    sac_->getModelCoefficients (coeff);

    // 如果用户需要优化模型参数
    if (optimize_coefficients_)
    {
        Eigen::VectorXf coeff_refined (model_->getModelSize ());
        model_->optimizeModelCoefficients (inliers.indices, coeff, coeff_refined);
        model_coefficients.values.resize (coeff_refined.size ());
        memcpy (&model_coefficients.values[0], &coeff_refined[0], coeff_refined.size () * sizeof (float));
        // Refine inliers
        model_->selectWithinDistance (coeff_refined, threshold_, inliers.indices);
    }else
    {
        model_coefficients.values.resize (coeff.size ());
        memcpy (&model_coefficients.values[0], &coeff[0], coeff.size () * sizeof (float));
    }

    ...

}

2. computeModel函数

上面的平面拟合计算主要在sac_->computeModel (0)这一句。也就是在computeModel这个模板函数,在PCL源码ransac.hpp中实现。为了方便阅读理解,精简了源码。

template <typename PointT> bool
pcl::RandomSampleConsensus<PointT>::computeModel (int)
{
  // Warn and exit if no threshold was set
  if (threshold_ == std::numeric_limits<double>::max())
  {
    PCL_ERROR ("[pcl::RandomSampleConsensus::computeModel] No threshold set!\n");
    return (false);
  }

  iterations_ = 0;
  std::size_t n_best_inliers_count = 0;
  double k = std::numeric_limits<double>::max();

  Indices selection;
  Eigen::VectorXf model_coefficients (sac_model_->getModelSize ());

  const double log_probability  = std::log (1.0 - probability_);
  const double one_over_indices = 1.0 / static_cast<double> (sac_model_->getIndices ()->size ());

  unsigned skipped_count = 0;

  // suppress infinite loops by just allowing 10 x maximum allowed iterations for invalid model parameters!
  const unsigned max_skip = max_iterations_ * 10;

  int threads = threads_;

    // Iterate
    while (true) // infinite loop with four possible breaks
    {
      // Get X samples which satisfy the model criteria
      // 随机获取样本
      {
        sac_model_->getSamples (iterations_, selection); // The random number generator used when choosing the samples should not be called in parallel
      }

      if (selection.empty ())
      {
        PCL_ERROR ("[pcl::RandomSampleConsensus::computeModel] No samples could be selected!\n");
        break;
      }

      // Search for inliers in the point cloud for the current plane model M
      // 计算模型参数
      if (!sac_model_->computeModelCoefficients (selection, model_coefficients)) // This function has to be thread-safe
      {
        //++iterations_;
        unsigned skipped_count_tmp;

        skipped_count_tmp = ++skipped_count;
        if (skipped_count_tmp < max_skip)
          continue;
        else
          break;
      }

      // Select the inliers that are within threshold_ from the model
      //sac_model_->selectWithinDistance (model_coefficients, threshold_, inliers);
      //if (inliers.empty () && k > 1.0)
      //  continue;

      // 计算点到平面的距离
      std::size_t n_inliers_count = sac_model_->countWithinDistance (model_coefficients, threshold_); // This functions has to be thread-safe. Most work is done here

      std::size_t n_best_inliers_count_tmp;

      n_best_inliers_count_tmp = n_best_inliers_count;

      if (n_inliers_count > n_best_inliers_count_tmp) // This condition is false most of the time, and the critical region is not entered, hopefully leading to more efficient concurrency
      {
          // Better match ?
          if (n_inliers_count > n_best_inliers_count)
          {
            n_best_inliers_count = n_inliers_count; // This write and the previous read of n_best_inliers_count must be consecutive and must not be interrupted!
            n_best_inliers_count_tmp = n_best_inliers_count;

            // Save the current model/inlier/coefficients selection as being the best so far
            model_              = selection;
            model_coefficients_ = model_coefficients;

            // Compute the k parameter (k=std::log(z)/std::log(1-w^n))
            // 计算迭代次数 k 
            const double w = static_cast<double> (n_best_inliers_count) * one_over_indices;
            double p_no_outliers = 1.0 - std::pow (w, static_cast<double> (selection.size ()));
            p_no_outliers = (std::max) (std::numeric_limits<double>::epsilon (), p_no_outliers);       // Avoid division by -Inf
            p_no_outliers = (std::min) (1.0 - std::numeric_limits<double>::epsilon (), p_no_outliers);   // Avoid division by 0.
            k = log_probability / std::log (p_no_outliers);
          } 
      }

      int iterations_tmp;
      double k_tmp;

      iterations_tmp = ++iterations_;

      k_tmp = k;

      if (iterations_tmp > k_tmp)
        break;
      if (iterations_tmp > max_iterations_)
      {
        PCL_DEBUG ("[pcl::RandomSampleConsensus::computeModel] RANSAC reached the maximum number of trials.\n");
        break;
      }
    } // while



  if (model_.empty ())
  {
    PCL_ERROR ("[pcl::RandomSampleConsensus::computeModel] RANSAC found no model.\n");
    inliers_.clear ();
    return (false);
  }

  // Get the set of inliers that correspond to the best model found so far
  sac_model_->selectWithinDistance (model_coefficients_, threshold_, inliers_);
  return (true);
}

3. computeModelCoefficients函数

然后可以看到,computeModelCoefficients 函数,就是用于计算平面参数的,即平面法向量,和上面的理论一一对应:

template <typename PointT> bool
pcl::SampleConsensusModelPlane<PointT>::computeModelCoefficients (
      const Indices &samples, Eigen::VectorXf &model_coefficients) const
{
  // Need 3 samples
  if (samples.size () != sample_size_)
  {
    PCL_ERROR ("[pcl::SampleConsensusModelPlane::computeModelCoefficients] Invalid set of samples given (%lu)!\n", samples.size ());
    return (false);
  }

  pcl::Array4fMapConst p0 = (*input_)[samples[0]].getArray4fMap ();
  pcl::Array4fMapConst p1 = (*input_)[samples[1]].getArray4fMap ();
  pcl::Array4fMapConst p2 = (*input_)[samples[2]].getArray4fMap ();

  // Compute the segment values (in 3d) between p1 and p0
  Eigen::Array4f p1p0 = p1 - p0;
  // Compute the segment values (in 3d) between p2 and p0
  Eigen::Array4f p2p0 = p2 - p0;

  // Avoid some crashes by checking for collinearity here
  Eigen::Array4f dy1dy2 = p1p0 / p2p0;
  if ( p2p0.isZero() || ((dy1dy2[0] == dy1dy2[1]) && (dy1dy2[2] == dy1dy2[1])) )          // Check for collinearity
  {
    return (false);
  }

  // Compute the plane coefficients from the 3 given points in a straightforward manner
  // calculate the plane normal n = (p2-p1) x (p3-p1) = cross (p2-p1, p3-p1)
  model_coefficients.resize (model_size_);
  model_coefficients[0] = p1p0[1] * p2p0[2] - p1p0[2] * p2p0[1];
  model_coefficients[1] = p1p0[2] * p2p0[0] - p1p0[0] * p2p0[2];
  model_coefficients[2] = p1p0[0] * p2p0[1] - p1p0[1] * p2p0[0];
  model_coefficients[3] = 0.0f;

  // Normalize
  model_coefficients.normalize ();

  // ... + d = 0
  model_coefficients[3] = -1.0f * (model_coefficients.template head<4>().dot (p0.matrix ()));

  PCL_DEBUG ("[pcl::SampleConsensusModelPlane::computeModelCoefficients] Model is (%g,%g,%g,%g).\n",
             model_coefficients[0], model_coefficients[1], model_coefficients[2], model_coefficients[3]);
  return (true);
}

4. countWithinDistance函数

得到平面方程之后,需要计算点到平面的距离 countWithinDistance函数。

template <typename PointT> std::size_t
pcl::SampleConsensusModelPlane<PointT>::countWithinDistance (
      const Eigen::VectorXf &model_coefficients, const double threshold) const
{
  // Needs a valid set of model coefficients
  if (!isModelValid (model_coefficients))
  {
    PCL_ERROR ("[pcl::SampleConsensusModelPlane::countWithinDistance] Given model is invalid!\n");
    return (0);
  }
  return countWithinDistanceStandard (model_coefficients, threshold);
}


pcl::SampleConsensusModelPlane<PointT>::countWithinDistanceStandard (
      const Eigen::VectorXf &model_coefficients, const double threshold, std::size_t i) const
{
  std::size_t nr_p = 0;
  // Iterate through the 3d points and calculate the distances from them to the plane
  for (; i < indices_->size (); ++i)
  {
    // Calculate the distance from the point to the plane normal as the dot product
    // D = (P-A).N/|N|
    Eigen::Vector4f pt ((*input_)[(*indices_)[i]].x,
                        (*input_)[(*indices_)[i]].y,
                        (*input_)[(*indices_)[i]].z,
                        1.0f);
    if (std::abs (model_coefficients.dot (pt)) < threshold)
    {
      nr_p++;
    }
  }
  return (nr_p);
}

值得注意的是,这里只有dot product,而公式:
拟合平面输出平面法向量 python实现 平面拟合原理_算法_45

是有拟合平面输出平面法向量 python实现 平面拟合原理_平面_46,而代码中没有是因为computeModelCoefficients函数中已经normalize法向量了。

Conclusion

至此,RANSAC 平面拟合的理论和相关的PCL源码学习到此了。但是RANSAC经过了那么多年的发展,已经衍生了很多优化版本。以后有机会会继续学习的。

Reference

  1. PCL平面分割, https://pcl.readthedocs.io/projects/tutorials/en/master/planar_segmentation.html#planar-segmentation
  2. 点到平面距离, https://baike.baidu.com/item/%E7%82%B9%E5%88%B0%E5%B9%B3%E9%9D%A2%E8%B7%9D%E7%A6%BB/10690055?fr=aladdin