前言

在自动驾驶系统中,通常会用起点、终点和一个三阶多项式来表示一条车道线,多项式系数的求解一般用最小二乘法来实现。

本文首先介绍两种基于最小二乘法的多项式拟合方法的原理,然后基于OpenCVc++编写了这两种拟合方法的代码,最后通过一个完整的示例来展示如何通过一个离散点集拟合出一条多项式曲线。

基于最小二乘法的多项式拟合原理推导

代数方式求解

多项式曲线拟合是指基于一系列的观测点去寻找一个多项式来表示这些点的关系,最小二乘法通过最小化误差的平方和去寻找数据的最佳匹配函数。假设有点集

opencv 波形曲线识别 opencv曲线拟合_多项式

其中,opencv 波形曲线识别 opencv曲线拟合_多项式_02opencv 波形曲线识别 opencv曲线拟合_opencv 波形曲线识别_03的关系满足函数

opencv 波形曲线识别 opencv曲线拟合_最小二乘法_04

假设opencv 波形曲线识别 opencv曲线拟合_机器学习_05次多项式函数为

opencv 波形曲线识别 opencv曲线拟合_c++_06

其中opencv 波形曲线识别 opencv曲线拟合_最小二乘法_07为多项式系数。如果要用这个opencv 波形曲线识别 opencv曲线拟合_机器学习_05次多项式来表示opencv 波形曲线识别 opencv曲线拟合_最小二乘法_09opencv 波形曲线识别 opencv曲线拟合_opencv 波形曲线识别_10的关系,那么多项式值与真实值之间的误差为

opencv 波形曲线识别 opencv曲线拟合_c++_11

采用最小二乘法进行多项式拟合的目的就是寻找一组最佳的多项式系数使得拟合后整个点集的总误差最小,而求总误差最小的问题可以转化为求误差平方和最小。整个点集的误差平方和为

opencv 波形曲线识别 opencv曲线拟合_最小二乘法_12

要使opencv 波形曲线识别 opencv曲线拟合_c++_13最小,可以对opencv 波形曲线识别 opencv曲线拟合_多项式_14(opencv 波形曲线识别 opencv曲线拟合_机器学习_15)求偏导并令其为零:

opencv 波形曲线识别 opencv曲线拟合_c++_16

可得

opencv 波形曲线识别 opencv曲线拟合_c++_17

写成矩阵形式可得

opencv 波形曲线识别 opencv曲线拟合_多项式_18

把上式写为opencv 波形曲线识别 opencv曲线拟合_机器学习_19,那么只要计算出

opencv 波形曲线识别 opencv曲线拟合_最小二乘法_20

opencv 波形曲线识别 opencv曲线拟合_c++_21

,再代入上面的式子中构建出矩阵opencv 波形曲线识别 opencv曲线拟合_opencv 波形曲线识别_22opencv 波形曲线识别 opencv曲线拟合_多项式_23,那么多项式系数opencv 波形曲线识别 opencv曲线拟合_c++_24就可以通过求解线性方程组得到。

矩阵方式求解

从上一节的推导过程可以看到,用代数法求解多项式系数的方法非常繁琐而且计算量比较大,我们其实还可以用矩阵求解的方法来简化计算过程。

opencv 波形曲线识别 opencv曲线拟合_opencv 波形曲线识别_25

那么整个点集的误差平方和可以表示为

opencv 波形曲线识别 opencv曲线拟合_最小二乘法_26

opencv 波形曲线识别 opencv曲线拟合_c++_24求导可得

opencv 波形曲线识别 opencv曲线拟合_c++_28

令导数为零,那么可以得到

opencv 波形曲线识别 opencv曲线拟合_机器学习_29

解得

opencv 波形曲线识别 opencv曲线拟合_opencv 波形曲线识别_30

上面求导过程中用到了几个矩阵求导的性质:

opencv 波形曲线识别 opencv曲线拟合_机器学习_31

opencv 波形曲线识别 opencv曲线拟合_最小二乘法_32

  1. 如果opencv 波形曲线识别 opencv曲线拟合_opencv 波形曲线识别_33是对称矩阵,那么

opencv 波形曲线识别 opencv曲线拟合_多项式_34

所以有

opencv 波形曲线识别 opencv曲线拟合_多项式_35

opencv 波形曲线识别 opencv曲线拟合_最小二乘法_36

多项式曲线拟合的C++代码实现

学习了前面的理论知识,我们用c++来编码实现一下,毕竟光看一堆数学公式还是不够直观。从前面的理论知识可以知道,用最小二乘法做多项式曲线拟合其实质就是一个矩阵求解的问题,我们可以借助一些常用的数学库比如OpenCVEigen来求解。本文将采用OpenCV来实现。

1. 代数方式求解的C++代码实现

如果理解了前面的理论,写代码其实就比较简单了。对照前面理论部分的公式,构建好矩阵AB,然后调用OpenCVsolve()函数来求解即可:

// 代数方式求解
void PolyFit(const std::vector<cv::Point2f> &points, const int order,
             cv::Mat *coeff) {
  const int n = points.size();
  cv::Mat A = cv::Mat::zeros(order + 1, order + 1, CV_64FC1);
  cv::Mat B = cv::Mat::zeros(order + 1, 1, CV_64FC1);

  // 构建A矩阵
  for (int i = 0; i < order + 1; ++i) {
    for (int j = 0; j < order + 1; ++j) {
      for (int k = 0; k < n; ++k) {
        A.at<double>(i, j) += std::pow(points.at(k).x, i + j);
      }
    }
  }

  // 构建B矩阵
  for (int i = 0; i < order + 1; ++i) {
    for (int k = 0; k < n; ++k) {
      B.at<double>(i, 0) += std::pow(points.at(k).x, i) * points.at(k).y;
    }
  }

  (*coeff) = cv::Mat::zeros(order + 1, 1, CV_64FC1);
  // 求解
  if (!cv::solve(A, B, *coeff, cv::DECOMP_LU)) {
    std::cout << "Failed to solve !" << std::endl;
  }
}

2. 矩阵方式求解的C++代码实现

矩阵方式求解的代码更加简单:

// 矩阵方式求解
void PolyFit(const std::vector<cv::Point2f> &points, const int order,
             cv::Mat *coeff) {
  const int n = points.size();
  cv::Mat A = cv::Mat::ones(n, order + 1, CV_64FC1);
  cv::Mat B = cv::Mat::zeros(n, 1, CV_64FC1);

  for (int i = 0; i < n; ++i) {
    const double a = points.at(i).x;
    const double b = points.at(i).y;
    B.at<double>(i, 0) = b;

    for (int j = 0, v = 1.0; j < order + 1; ++j, v *= a) {
      A.at<double>(i, j) = v;
    }
  }

  // 使用cv::solve求解(A^T * A) * coeff = A^T * B
  cv::Mat At = A.t();
  cv::Mat AtA = At * A;
  cv::Mat AtB = At * B;
  cv::solve(AtA, AtB, *coeff, cv::DECOMP_NORMAL);
}

3. 一个完整的多项式曲线拟合示例

接下来,我们来看一个简单的曲线拟合示例:

int main(int argc, char **argv) {
  constexpr int kOrder = 3; // 多项式阶数
  constexpr int kWidth = 1000;
  constexpr int kHeight = 500;

  cv::Mat canvas =
      cv::Mat(cv::Size(kWidth, kHeight), CV_8UC3, cv::Scalar(255, 255, 255));

  cv::RNG rng(0xFFFFFFFF); // 随机数

  // 生成点集,y坐标添加一些随机噪声
  std::vector<cv::Point2f> raw_points;
  for (int i = 10; i < kWidth; i += 10) {
    cv::Point2f p;
    p.x = i;
    const auto noise = rng.uniform(-kHeight / 10, kHeight / 10);
    p.y = kHeight - p.x / kWidth * kHeight + noise;

    cv::circle(canvas, p, 5, cv::Scalar(0, 0, 255), -1);
    raw_points.emplace_back(p);
  }

  // 多项式拟合
  cv::Mat coeff;
  PolyFit(raw_points, kOrder, &coeff);

  // 用拟合后的系数重新生成点集
  std::vector<cv::Point> poly_points;
  for (const auto &rp : raw_points) {
    cv::Point p;
    p.x = rp.x;
    p.y = PolyValue(coeff, kOrder, rp.x);

    cv::circle(canvas, p, 5, cv::Scalar(0, 255, 0), -1);
    poly_points.emplace_back(p);
  }
  
  cv::polylines(canvas, poly_points, false, cv::Scalar(0, 255, 0), 3,
                cv::LINE_4);

  cv::imshow("PolyFit", canvas);
  cv::waitKey(0);
  cv::destroyAllWindows();

  return 0;
}

代码的流程如下:

  1. 生成一个离散点集,其中每个点的y坐标被添加一定的随机噪声;
  2. 调用前面实现的多项式拟合函数求解多项式系数;
  3. 用求解出的多项式系数重新计算每个点的y坐标值;
  4. 可视化原始点集和拟合后的点集。

其中用于计算多项式值的函数PolyValue()实现如下:

float PolyValue(const cv::Mat &coeff, const int order, const float x) {
  float v = 0;
  for (int i = 0; i < order; ++i) {
    v += coeff.at<double>(i, 0) * std::pow(x, i);
  }
  return v;
}

可视化的结果如下图所示,其中红色点为原始点,绿色点为拟合后的点。

opencv 波形曲线识别 opencv曲线拟合_机器学习_37