《点云处理》平面拟合

前言

在众多点云处理算法中,其中关于平面拟合的算法十分广泛。本篇内容主要是希望总结归纳各类点云平面拟合算法,并且将代码进行梳理保存。

环境:

VS2019 + PCL1.11.1

1.RANSAC

使用ransac对平面进行拟合是非常常见的用法,PCL库中就有RANSAC拟合平面的实现代码,而且还集成了 两种拟合平面的代码。
方法一:

/// <summary>
/// 使用PCL中集成的RANSAC方法进行平面拟合
/// </summary>
/// <param name="cloud_in">输入待拟合的点云</param>
/// <param name="inliers">RANSAC拟合得到的内点</param>
/// <param name="coefficients">得到的平面方程参数</param>
/// <param name="iterations">平面拟合最大迭代次数</param>
/// <param name="threshold">RANSAC拟合算法距离阈值</param>
void RANSAC(const pcl::PointCloud<pcl::PointXYZ>::Ptr& cloud_in, std::vector<int>& inliers, Eigen::VectorXf& coefficients,
    const int& iterations, const double& threshold)
{
    inliers.clear();                                          // 用于存放内点索引的vector
    pcl::shared_ptr<pcl::SampleConsensusModelPlane<pcl::PointXYZ>> model(new pcl::SampleConsensusModelPlane<pcl::PointXYZ>(cloud_in)); //定义待拟合平面的model,并使用待拟合点云初始化
    pcl::RandomSampleConsensus<pcl::PointXYZ> ransac(model);  // 定义RANSAC算法模型
    ransac.setDistanceThreshold(threshold);                   // 设定阈值
    ransac.setMaxIterations(iterations);                      // 设置最大迭代次数
    ransac.setNumberOfThreads(10);                            // 设置线程数量
    ransac.computeModel();                                    // 拟合 
    ransac.getInliers(inliers);                               // 获取内点索引
    Eigen::VectorXf params;                                   // 第一次得到的平面方程
    ransac.getModelCoefficients(params);                      // 获取拟合平面参数,对于平面ax+by_cz_d=0,params分别按顺序保存a,b,c,d
    model->optimizeModelCoefficients(inliers, params, coefficients);      // 优化平面方程参数
    std::vector<double> vDistance;                            // 用于存储每个点到拟合平面的距离的vector容器
    model->getDistancesToModel(coefficients, vDistance);      // 得到每个点到平面的距离的集合
    std::cout << "params: " << params[0] << ", " << params[1] << ", " << params[2] << ", " << params[3] << std::endl;
    std::cout << "coefficients: " << coefficients[0] << ", " << coefficients[1] << ", " << coefficients[2] << ", " << coefficients[3] << std::endl;
}

上述代码需要注意的是,一定需要model->optimizeModelCoefficients(inliers, params, coefficients); 通过这一句代码去优化平面参数。优化前后差别很大,如下图所示:
在这里插入图片描述
方法二:

/// <summary>
/// 使用PCL中集成的用于点云分割的RANSAC方法进行平面拟合
/// </summary>
/// <param name="cloud_in">输入待拟合的点云</param>
/// <param name="inliers">RANSAC拟合得到的内点</param>
/// <param name="coefficients">得到的平面方程参数</param>
/// <param name="iterations">平面拟合最大迭代次数</param>
/// <param name="threshold">RANSAC拟合算法距离阈值</param>
void SEG_RANSAC(const pcl::PointCloud<pcl::PointXYZ>::Ptr& cloud_in, pcl::PointIndices::Ptr& inliers, Eigen::VectorXf& coefficients,
    const int& iterations, const double& threshold)
{
    if (inliers == nullptr) inliers.reset(new pcl::PointIndices);
    pcl::ModelCoefficients::Ptr coefficients_m(new pcl::ModelCoefficients);
    pcl::SACSegmentation<pcl::PointXYZ> seg;
    seg.setOptimizeCoefficients(true);
    seg.setModelType(pcl::SACMODEL_PLANE);
    seg.setMethodType(pcl::SAC_RANSAC);
    seg.setMaxIterations(iterations);                     // 设置最大迭代次数
    seg.setDistanceThreshold(threshold);                  // 设定阈值
    seg.setNumberOfThreads(10);                           // 设置线程数量
    seg.setInputCloud(cloud_in);
    seg.segment(*inliers, *coefficients_m);

    coefficients.resize(4);
    coefficients[0] = coefficients_m->values[0]; coefficients[1] = coefficients_m->values[1];
    coefficients[2] = coefficients_m->values[2]; coefficients[3] = coefficients_m->values[3];
    std::cout << "SEG coefficients: " << coefficients[0] << ", " << coefficients[1] << ", " << coefficients[2] << ", " << coefficients[3] << std::endl;
}

方法二其实也提到了优化平面参数,seg.setOptimizeCoefficients(true);这句代码就是比较关键的,需要加上。
上述两种方法的运行结果如下,从结果和运行时间来看,两种方法几乎一致。
在这里插入图片描述
两种方法中都有一个设置线程的功能setNumberOfThreads(10); 但是这句话根本没有起到作用,下图中有提示[pcl::RandomSampleConsensus::computeModel] Parallelization is requested, but OpenMP 3.1 is not available! Continuing without parallelization.其实在VS中已经开启了openmp,也包含了头文件#include <omp.h>,只不过版本不对应,该问题也查阅了很久,没查到解决办法。

VS中开启openmp加速的配置方法:
在这里插入图片描述
关于方法二:
其实使用pcl::SACSegmentationpcl::PointXYZ seg进行平面拟合时,除了上面所给出的代码中那几个常规参数设置外,还有几个参数:

 // 设置角度容差(EpsAngle)。它定义了平面模型的法向量与给定轴之间的最大允许角度差异。只有当模型类型(setModelType)设置为SACMODEL_NORMAL_PARALLEL_PLANE时才有效。
seg_pos.setEpsAngle();
// 设置用于分割的轴向量。它定义了与平面模型垂直的方向。只有当模型类型(setModelType)设置为SACMODEL_NORMAL_PARALLEL_PLANE时才有效。
seg_pos.setAxis();
// Set the maximum distance allowed when drawing random samples
seg_pos.setSamplesMaxDist();

关于setAxis(),其说明是Set the axis along which we need to search for a model perpendicular to.简单理解就是找到一个模型能够垂直于这个轴。那么显然这个参数是为拟合平面而存在的。那么setModelType就需要设置为pcl::SACMODEL_NORMAL_PARALLEL_PLANE,意思是设置分割模型为带约束的平面。既然设置了轴,那么就要设置运行容差的角度setEpsAngle()。关于不同模型的含义请点击。
上述这两种参数设置,这里给出了相应的例子。关于这种用法,可以应用在提前使用了PCA或者其他方法得到了想要拟合平面的法向,使用这个法向进行约束可以得到更加精确的平面。个人认为这个提前设置的法向必须要足够精确,否则反而会干扰平面方程的拟合过程。

第三个参数则是seg_pos.setSamplesMaxDist();一般情况下都会认为同一平面上的点都会比较接近。那么就可以使用该方法对其进行设置。下面的例程来源于这里。第一参数就是搜索半径。这其实也有个问题,通常一个平面点云,小范围内都是非常平整的。假如有两块区域相离比较远,可能拟合出来的平面并不一定很重合。所以如果采用了这种方法进行限定,那么有必要将距离阈值设置的稍微大一点,否则提取得到的内点可能会残缺。

#include <pcl/search/kdtree.h> 
pcl::search::KdTree<pcl::PointXYZ>::Ptr tree(new pcl::search::KdTree<pcl::PointXYZ>);
tree->setInputCloud(cloud_copy);
seg.setSamplesMaxDist(3, tree);

2.最小二乘法拟合平面

除了RANSAC之外,另一种十分常见的拟合方程的方法是最小二乘法,这里就不过多介绍相关的理论知识,这里提到了相关的代码和原理

当然还是要贴上封装的C++代码实现:

/// <summary>
/// 使用最小二乘法拟合平面:Ax + By + Cz + D = 0 */   //拉格朗日乘子法 https://zhuanlan.zhihu.com/p/390294059
/// </summary>
/// <param name="xjData">输入待拟合平面的点云</param>
/// <param name="coefficients">输出拟合的平面方程</param>
/// <returns>输入点云点数符合要求,返回true,否则返回false</returns>
bool LeastSquare(const pcl::shared_ptr<pcl::PointCloud<pcl::PointXYZ>>& xjData, Eigen::VectorXf& coefficients)
{
    int count = xjData->points.size();
    if (count < 3) return false;

    double meanX = 0, meanY = 0, meanZ = 0;
    double meanXX = 0, meanYY = 0, meanZZ = 0;
    double meanXY = 0, meanXZ = 0, meanYZ = 0;
    for (int i = 0; i < count; i++)
    {
        meanX += xjData->points[i].x;
        meanY += xjData->points[i].y;
        meanZ += xjData->points[i].z,

        meanXX += xjData->points[i].x * xjData->points[i].x;
        meanYY += xjData->points[i].y * xjData->points[i].y;
        meanZZ += xjData->points[i].z * xjData->points[i].z;

        meanXY += xjData->points[i].x * xjData->points[i].y;
        meanXZ += xjData->points[i].x * xjData->points[i].z;
        meanYZ += xjData->points[i].y * xjData->points[i].z;
    }
    meanX /= count; meanY /= count; meanZ /= count;
    meanXX /= count; meanYY /= count; meanZZ /= count;
    meanXY /= count; meanXZ /= count; meanYZ /= count;

    /* eigenvector */
    Eigen::Matrix3d eMat;
    eMat(0, 0) = meanXX - meanX * meanX; eMat(0, 1) = meanXY - meanX * meanY; eMat(0, 2) = meanXZ - meanX * meanZ;
    eMat(1, 0) = meanXY - meanX * meanY; eMat(1, 1) = meanYY - meanY * meanY; eMat(1, 2) = meanYZ - meanY * meanZ;
    eMat(2, 0) = meanXZ - meanX * meanZ; eMat(2, 1) = meanYZ - meanY * meanZ; eMat(2, 2) = meanZZ - meanZ * meanZ;
    Eigen::EigenSolver<Eigen::Matrix3d> xjMat(eMat);           // 求取矩阵特征值和特征向量的函数EigenSolver
    Eigen::Matrix3d eValue = xjMat.pseudoEigenvalueMatrix();   // 获取矩阵伪特征值 3*3
    Eigen::Matrix3d eVector = xjMat.pseudoEigenvectors();      // 获取矩阵伪特征向量 3*1

    /* the eigenvector corresponding to the minimum eigenvalue */
    double v1 = eValue(0, 0); double v2 = eValue(1, 1); double v3 = eValue(2, 2);
    int minNumber = 0;
    if ((abs(v2) <= abs(v1)) && (abs(v2) <= abs(v3)))
    {
        minNumber = 1;
    }
    if ((abs(v3) <= abs(v1)) && (abs(v3) <= abs(v2)))
    {
        minNumber = 2;
    }
    double A = eVector(0, minNumber); double B = eVector(1, minNumber); double C = eVector(2, minNumber);
    double length = sqrt(A * A + B * B + C * C);
    A /= length; B /= length; C /= length;
    double D = -(A * meanX + B * meanY + C * meanZ);

    /* result */
    if (C < 0)
    {
        A *= -1.0; B *= -1.0; C *= -1.0; D *= -1.0;
    }

    coefficients.resize(4);
    coefficients[0] = A; coefficients[1] = B; coefficients[2] = C; coefficients[3] = D;

    std::cout << "LS coefficients: " << coefficients[0] << ", " << coefficients[1] << ", " << coefficients[2] << ", " << coefficients[3] << std::endl;
}

同一份点云,使用最小二乘法拟合结果如下:
在这里插入图片描述
最小二乘法拟合平面方程和上述RANSAC拟合结果还是有些差别的,但是RANSAC运行时间约为0.02s,而最小二乘法运行时间需要0.0013s,最小二乘法运行时间要比RANSAC快很多。如果针对一份没有太多噪点,很平滑干净的点云,可以使用最小二乘法去拟合,但是如果是有噪点的点云,追求准确率的情况下,则需要使用RANSAC进行拟合。

3.SVD分解的方法求平面方程

可以通过Eigen实现使用SVD分解的方法进行平面方程求解

/// <summary>
/// 通过SVD分解的方法求拟合平面
/// </summary>
/// <param name="pcl_cloud">输入待拟合平面点云</param>
/// <param name="coefficients">输出拟合平面的参数</param>
void PlaneEigenSVD(const pcl::PointCloud<pcl::PointXYZ>& pcl_cloud, Eigen::VectorXf& coefficients)
{
    if (pcl_cloud.points.size() < 3) return;
    // Convert PCL point cloud to Eigen matrix
    Eigen::Matrix<float, 3, Eigen::Dynamic> coord(3, pcl_cloud.size());
    for (size_t i = 0; i < pcl_cloud.size(); ++i)
    {
        coord.col(i) << pcl_cloud[i].x, pcl_cloud[i].y, pcl_cloud[i].z;
    }

    // Calculate centroid
    Eigen::Vector4f centroid;
    pcl::compute3DCentroid(pcl_cloud, centroid);

    // Subtract centroid
    coord.row(0).array() -= centroid(0);
    coord.row(1).array() -= centroid(1);
    coord.row(2).array() -= centroid(2);

    // Perform singular value decomposition (SVD)
    Eigen::JacobiSVD<Eigen::Matrix<float, Eigen::Dynamic, Eigen::Dynamic>> svd(coord, Eigen::ComputeThinU | Eigen::ComputeThinV);
    Eigen::Vector3f plane_normal = svd.matrixU().rightCols<1>();

    // Create PCL ModelCoefficients
    coefficients.resize(4);
    coefficients[0] = plane_normal(0);
    coefficients[1] = plane_normal(1);
    coefficients[2] = plane_normal(2);
    coefficients[3] = -(plane_normal(0) * centroid(0) + plane_normal(1) * centroid(1) + plane_normal(2) * centroid(2));
    if (coefficients[2] < 0) coefficients = -coefficients;

    std::cout << "SVD coefficients: " << coefficients[0] << ", " << coefficients[1] << ", " << coefficients[2] << ", " << coefficients[3] << std::endl;

    return;
}

运行结果如下:
在这里插入图片描述
得到的结果其实和最小二乘法拟合得到的结果一样,但是耗时更长一些。

更新SVD分解的另一种写法,代码如下:

/// <summary>
/// 使用Eigen SVD分解获取平面方程
/// </summary>
/// <param name="cloud_in">输入点云</param>
/// <param name="coefficients">得到的平面方程</param>
/// <returns>return true表示拟合成功,return false表示拟合失败</returns>
bool LeastSquareByEigen(const pcl::PointCloud<pcl::PointXYZ>::Ptr& cloud_in, Eigen::VectorXf& coefficients)
{
    if (cloud_in == nullptr) return false;
    if (cloud_in->points.size() < 10) return false;
    // copy coordinates to  matrix in Eigen format
    size_t num_atoms = cloud_in->points.size();
    Eigen::Matrix< Eigen::Vector3f::Scalar, Eigen::Dynamic, Eigen::Dynamic > coord(3, num_atoms);
    for (size_t i = 0; i < num_atoms; ++i) coord.col(i) = cloud_in->points[i].getVector3fMap();

    // calculate centroid
    Eigen::Vector3f centroid(coord.row(0).mean(), coord.row(1).mean(), coord.row(2).mean());

    // subtract centroid
    coord.row(0).array() -= centroid(0); coord.row(1).array() -= centroid(1); coord.row(2).array() -= centroid(2);

    // we only need the left-singular matrix here
    //  http://math.stackexchange.com/questions/99299/best-fitting-plane-given-a-set-of-points
    auto svd = coord.jacobiSvd(Eigen::ComputeThinU | Eigen::ComputeThinV);
    Eigen::Vector3f plane_normal = svd.matrixU().rightCols<1>();
    
    coefficients.resize(4);
    coefficients[0] = plane_normal(0);
    coefficients[1] = plane_normal(1);
    coefficients[2] = plane_normal(2);
    coefficients[3] = -(plane_normal(0) * centroid(0) + plane_normal(1) * centroid(1) + plane_normal(2) * centroid(2));

    if (coefficients[2] < 0) coefficients = -coefficients;

    // Normalize the coefficients
    float norm = coefficients.head<3>().norm();
    coefficients /= norm;
    std::cout << "LeastSquareByEigen coefficients: " << coefficients[0] << ", " << coefficients[1] << ", " << coefficients[2] << ", " << coefficients[3] << std::endl;
    
    return true;
}

4.霍夫变换进行平面拟合

其实使用霍夫变换去拟合几何模型也是一件非常常见的方法,但是目前没有相关代码进行测试验证,如果后续有找到实现方法也会更新在这里。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/255307.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

C语言实现输入一个 N*N 矩阵,并将矩阵转置输出

完整代码&#xff1a; //输入一个 N*N 矩阵&#xff0c;并将矩阵转置输出 #include<stdio.h> #include<stdlib.h>int main(){int n0;printf("请输入矩阵的行数:");scanf("%d",&n);//C语言不允许对数组的大小作动态定义// int arr[n][n];直…

Linux之FTP 服务器

一、FTP服务器匿名账户服务器配置 1、测试是否已安装vsftp服务器&#xff1a; 2、启动vsftp服务器&#xff1a; 3、修改vsftp主配置文件&#xff0c;允许匿名登录 4、重新启动vsftpd服务,禁用防火墙 5、打开FTP服务的数据文件存放目录/var/ftp&#xff0c;复制若干文件到该目…

深度学习环境配置超详细教程【Anaconda+Pycharm+PyTorch(GPU版)+CUDA+cuDNN】

在宇宙的浩瀚中&#xff0c;我们是微不足道的&#xff0c;但我们的思维却可以触及无尽的边界。 目录 关于Anaconda&#xff1a; 关于Pycharm&#xff1a; 关于Pytorch&#xff1a; 关于CUDA&#xff1a; 关于Cudnn&#xff1a; 一、&#x1f30e;前言&#xff1a; 二、&…

深眸科技|轻辙视觉引擎以99.9%视觉检测能力为基准,赋能木材加工

轻辙视觉引擎&#xff1a;轻辙视觉引擎是以低代码为基础&#xff0c;深度学习技术为核心的视觉业务流程编排引擎&#xff0c;用于快速搭建部署复杂视觉检测流程软件方案。 轻辙视觉引擎&#xff5c;轻量级产品实现高效应用 作为深眸科技的核心产品之一&#xff0c;轻辙视觉引…

【Docker光速搞定深度学习环境配置!】

你是否还在用压缩包打包你的代码&#xff0c;然后在新的机器重新安装软件&#xff0c;配置你的环境&#xff0c;才能跑起来&#xff1f; 特别有这样的情况&#xff1a;诶&#xff0c;在我电脑跑的好好的&#xff0c;怎么这里这么多问题&#xff1f; 当项目比较简单的时候&am…

Java Web实现案例 :实现用户登录功能

目录 零、本节学习目标 一、纯JSP方式实现用户登录功能 &#xff08;一&#xff09;实现思路 &#xff08;二&#xff09;实现步骤 1、创建Web项目 2、创建登录页面 3、创建登录处理页面 4、创建登录成功页面 5、创建登录失败页面 6、编辑项目首页 &#xff08;三&…

全同态加密正在改变行业游戏规则?

PrimiHub一款由密码学专家团队打造的开源隐私计算平台&#xff0c;专注于分享数据安全、密码学、联邦学习、同态加密等隐私计算领域的技术和内容。 隐私专业人士正在见证隐私技术的一场革命。新的隐私增强技术的出现和成熟是这场革命的一部分&#xff0c;这些技术允许数据使用和…

Python---进程

1. 进程的介绍 在Python程序中&#xff0c;想要实现多任务可以使用进程来完成&#xff0c;进程是实现多任务的一种方式。 2. 进程的概念 一个正在运行的程序或者软件就是一个进程&#xff0c;它是操作系统进行资源分配的基本单位&#xff0c;也就是说每启动一个进程&#xf…

RNN梯度爆炸实验

前情回顾 from torch.utils.data import Dataset import torch.nn as nn import torch.nn.functional as F import os import random import torch from nndl import Accuracy from nndl import RunnerV3 from torch.utils.data import DataLoader import matplotlib.pyplot a…

Html+单页面引入element以及Vue框架引用地址报错(unpkg.com国内无法访问可代替方案)

问题 单页面引入element以及vue 地址报错&#xff0c;请求超时 使用的引用地址是官网上提供&#xff0c;但是没解决问题 一、原因&#xff1a; unpkg也没有幸免于难&#xff0c;也被墙了&#xff0c;unpkg上的相关资源都不能访问&#xff0c;才导致项目资源加载不出。 二、…

微服务——服务异步通讯(MQ高级)

MQ的一些常见问题 消息可靠性 生产者消息确认 返回ack&#xff0c;怎么感觉这么像某个tcp的3次握手。 使用资料提供的案例工程. 在图形化界面创建一个simple.queue的队列&#xff0c;虚拟机要和配置文件里面的一样。 SpringAMQP实现生产者确认 AMQP里面支持多种生产者确认的类…

MySQL job 定时任务

目录 介绍 优点&#xff1a; 缺点&#xff1a; 使用场景&#xff1a; 案例 创建表 -- 创建定时任务 每一分钟插入一条数据 执行结果 -- 查询定时任务 ENABLED--启用 DISABLED--禁用 -- 查询定时任务 -- 启用定时任务 ​-- 禁用定时任务 ​-- 删除定时任务 …

【mybatis】mapper.xml映射文件

目录 一.概述 二.了解mapper.xml文件 namespaceidresultType指定映射文件的路径 一.概述 mapper.xml 是一个 MyBatis 的映射文件&#xff0c;用于定义 SQL 语句和结果映射。它是一个 XML 文件&#xff0c;通常放置在项目的资源目录下。 随着mybatis框架的发展&#xff0c;myb…

Spring IOC 原理(二)

Spring IOC 原理 概念 Spring 通过一个配置文件描述 Bean 及 Bean 之间的依赖关系&#xff0c;利用 Java 语言的反射功能实例化Bean 并建立 Bean 之间的依赖关系。 Spring 的 IoC 容器在完成这些底层工作的基础上&#xff0c;还提供了 Bean 实例缓存、生命周期管理、 Bean 实…

数据结构与算法--贪心算法

贪心算法 应用场景 假设存在下面需要付费的广播台&#xff0c;以及广播台信号可以覆盖的地区。 如何选择最少的广播台&#xff0c;让所有的地区都可以接收到信号 介绍 贪婪算法(贪心算法)是指在对问题进行求解时&#xff0c;在每一步选择中都采取最好或者最优(即最有利)的选择…

第三周:Python能力复盘

资料&#xff1a; 《笨办法学Python》阅读地址&#xff1a;https://www.bookstack.cn/read/LearnPython3TheHardWay 《廖雪峰Python教程》阅读地址&#xff1a;http://t.cn/RK0qGu7 《机器学习numpy与pandas基础》&#xff1a;https://zhuanlan.zhihu.com/p/639733816 《matplo…

代码随想录算法训练营 | day55 动态规划 392.判断子序列,115.不同的子序列

刷题 392.判断子序列 题目链接 | 文章讲解 | 视频讲解 题目&#xff1a;给定字符串 s 和 t &#xff0c;判断 s 是否为 t 的子序列。 字符串的一个子序列是原始字符串删除一些&#xff08;也可以不删除&#xff09;字符而不改变剩余字符相对位置形成的新字符串。&#xff0…

SHT10温湿度传感器——STM32驱动

———————实验效果——————— &#x1f384;硬件外观 &#x1f384;接线 &#x1f388; 3.3V供电 &#x1f388; IIC通讯 &#x1f384; 代码获取 &#x1f388; 查看下方 ———————END———————

【深度学习初探】Day32 - 三维点云数据基础

【深度学习初探】Day32 - 三维点云数据基础 文章目录 【深度学习初探】Day32 - 三维点云数据基础一、点云的定义二、点云的获取三、点云的属性四、点云的存储格式4.1 pts4.2 LAS4.3 PCD4.4 .xyz4.5 .pcap 五、三维点云的表示方法5.1 二维投影5.2 三维体素5.3 原始点云5.4 图 六…

(已解决)如何使用matplotlib绘制小提琴图

网上很多人使用seaborn绘制小提琴图&#xff0c;本人暂时不想学新的东西&#xff0c;就是懒。本文介绍如何使用matplotlib绘制小提琴图&#xff0c;很多其他博客只是使用最简单的语法&#xff0c;默认小提琴颜色会是蓝色&#xff0c;根本改不了。本文使用了一点高级的用法&…