LOAM源代码分析附公式推导之MultiScanRegistration

2023-10-22 00:59

本文主要是介绍LOAM源代码分析附公式推导之MultiScanRegistration,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

LOAM源代码分析附公式推导

  • 更新日志
  • 1. 概述
  • 2. 前言
  • 3. 准备工作
    • 3.1 激光雷达
    • 3.2 IMU
    • 3.3 坐标变换
  • 4. 插话
  • 5. 之MultiScanRegistration
    • 5.1 参数配置
    • 5.2 话题订阅
    • 5.3 IMU数据处理
    • 5.4 点云数据处理
      • 5.4.1 点云数据
      • 5.4.2 近似匀速运动模型
      • 5.4.3 特征提取
      • 5.4.4 打包IMU数据
    • 5.5发布数据
  • 更新日志

更新日志

同步更新地址知乎:LOAM SLAM原理之源代码分析附公式推导
有细节性出入!!主要是细节部分的解疑,问题讨论部分,建议前往知乎阅读,此博客只是原始版本(还不是因为大家讨论得太少了,修改都在知乎上进行了 )

  • 2020-12-07
    整理完成LaserOdometry节点内容,地址请转到LOAM源代码分析附公式推导之LaserOdometry
    最近有点忙,因此这个时间跨度有点大,两个月了,我自己都没有想到!害,很多公式自己有重新推导了一次,希望能够帮助大家!
  • 2020-10-11
    整理完成MultiScanRegistration节点内容
  • 2020-10-09
    第一次编辑,初版

1. 概述

整理一下LOAM中对激光点云数据、IMU数据的处理以及相关的公式推导,希望能帮助大家,同时供自己以后复习参考。由于相关的东西较多,可能会有疏漏,并且由于时间有限,精力有限,此博客长期更新,欢迎各位小伙伴批评指正。

同步更新地址知乎:LOAM SLAM原理之源代码分析附公式推导,有细节性出入!!主要是细节部分的解疑问题讨论部分,建议前往知乎阅读,此博客只是原始版本(还不是因为大家讨论得太少了,修改都在知乎上进行了 )。

2. 前言

LOAM是 bulabula。
LOAM参考源代码仓库github地址为LOAM 源代码链接具体配置可以参照博客LOAM SLAM安装与配置
关于LOAM原始论文的解读可以参考LOAM SLAM之论文原理解读,
相关链接LOAM SLAM原理之防止非线性优化解退化。
其他参考链接,感谢各位在此之前分享对LOAM原理以及代码的分析理解
LOAM细节分析
LOAM:3D激光里程计及环境建图的方法和实现(一)

每个人对代码的理解是不一样,均有可能出现偏差,欢迎探讨!

3. 准备工作

在LOAM中,使用的坐标系是左上前坐标系,也就是X指向左Y指向上Z指向前。一定要注意坐标系问题,后面的一系列工作全都是基于这一基准坐标系开展的。
在这里插入图片描述

3.1 激光雷达

LOAM原始论文使用的是一个二维激光雷达,通过机械机构产生3D点云数据,但是现在的代码中大多使用的是Velodyne美国威力登公司的16线激光雷达:vlp16也称为PUCK。因此本文以代码中使用的vlp16雷达开展(代码使用的这个,如果想用其他的就自己改改吧,哈哈)。那么简单介绍一下vlp16:

  • Velodyne PUCK(VLP-16)16通道
  • TOF测量距离,最远100m,精度3cm
  • 垂直分辨率2°
  • 水平分辨率600、1200RPM对应0.2°、0.4°
  • 旋转速率5-20Hz
  • 最大点云数据量 360/0.4161200/60=288000(30万)

在这里插入图片描述

我们主要关注点云数据的结构,根据官方文档,具体结构示意如下图,在实际扫描中,激光雷达的激光束按照id号依次进行,例如发射id为0激光,发射id为1激光,最后发射id为15激光。
需要注意相邻激光束ID差别较大,也就是激光每次发射激光时不是角度上依次发射的,而是隔了半个垂直视场的范围,id相邻的激光束空间上不相邻
在这里插入图片描述

评论区有小伙伴对激光束发射提出疑问,此问题针对vlp16激光雷达,这里我解释一下,激光束是间断发射的16束垂直方向的激光,不是同时发射的,但是间断时间很短,你可以认为是同时发射的,只是认为。可以想一个问题,激光如果是同时发射的,如何根据反射的激光区分他们呢,所以一次只能发射一束激光。根据官方资料,在垂直方向发射激光时,相邻激光束时间差 2.304 μ s 2.304{\mu}s 2.304μs微秒,16束激光发射完成时会有 18.43 μ s 18.43{\mu}s 18.43μs微秒的空闲时间,一次16束激光完成发射需要花费 16 ∗ 2.034 μ s + 18.43 μ s = 55.296 μ s 16*2.034{\mu}s+18.43{\mu}s=55.296{\mu}s 162.034μs+18.43μs=55.296μs微秒。当水平旋转频率为600RPM时,对应的水平方向分辨率 A z i m u t h r e s o l u t i o n = 10 r e v / s ∗ 360 ° / r e v ∗ 55.296 × 1 0 − 6 s / f i r i n g c y c l e = 0.199 ° / f i r i n g c y c l e Azimuth_{resolution}=10rev/s*360\degree/rev*55.296\times10^{-6}s/firing cycle=0.199\degree/firingcycle Azimuthresolution=10rev/s360°/rev55.296×106s/firingcycle=0.199°/firingcycle,与之前给出的数据对应,这下应该都明白了吧。

在这里插入图片描述
vlp16的坐标系为右前上,并且扫描时顺时针旋转,通过安装可以将坐标系变换为前左上,因此我们在数据处理时只需要将前左上坐标系的点云数据转换为左上前坐标系即可。
在这里插入图片描述

3.2 IMU

根据代码可以看到IMU安装坐标系为前左上,因此实际使用时需要将测量的比力信号转换到左上前坐标系,并且注意对重力加速度的处理之后才是真正的激光运动加速度。

3.3 坐标变换

前面谈到了坐标系问题,这里讲一下坐标变换的问题,网上相关的博客很多,为了不引起误解,对于LOAM,我们统一用下面的方式描述旋转变换:
乘,旋转坐标轴先Z再X后Y得到旋转矩阵 R Z X Y R_{ZXY} RZXY
举例说明:激光坐标系 L L L和世界坐标系 W W W原点开始时一样,经过绕Z轴旋转 r z rz rz角度,绕X轴 r x rx rx角度,绕Y轴 r y ry ry角度(以坐标轴正方向为旋转正方向)之后得到激光坐标系 L ′ L' L,那么根据上面的定义有
从世界坐标系 W W W到激光坐标系 L ′ L' L的变换 R Z X Y R_{ZXY} RZXY
根据左乘
R Z X Y = R r y R r x R r z R_{ZXY}=R_{ry}R_{rx}R_{rz} RZXY=RryRrxRrz
根据旋转坐标轴矩阵
R Z X Y = [ c r y 0 − s r y 0 1 0 s r y 0 c r y ] [ 1 0 0 0 c r x s r x 0 − s r x c r x ] [ c r z s r z 0 − s r z c r z 0 0 0 1 ] R_{ZXY}= \begin{bmatrix} cry& 0 &-sry \\ 0 & 1 & 0 \\ sry & 0 & cry \end{bmatrix} \begin{bmatrix} 1& 0 &0 \\ 0 & crx & srx \\ 0 & -srx & crx \end{bmatrix} \begin{bmatrix} crz& srz &0 \\ -srz & crz& 0 \\ 0 & 0 & 1 \end{bmatrix} RZXY=cry0sry010sry0cry1000crxsrx0srxcrxcrzsrz0srzcrz0001
进一步得到
R Z X Y = [ c y c z − s x s y s z c y s z + s x s y c z − s y c x − c x s z c x c z s x s y c z + s x c y s z s y s z − s x c y c z c y c x ] R_{ZXY}= \begin{bmatrix} cycz-sxsysz& cysz+sxsycz &-sycx \\ -cxsz & cxcz& sx \\ sycz+sxcysz & sysz-sxcycz & cycx \end{bmatrix} RZXY=cyczsxsyszcxszsycz+sxcyszcysz+sxsyczcxczsyszsxcyczsycxsxcycx
上面的公式以及以后的公式中 s y = s r y = s i n ( r y ) , c y = c r y = c o s ( r y ) sy=sry=sin(ry),cy=cry=cos(ry) sy=sry=sin(ry),cy=cry=cos(ry)
但有的时候,我们需要的是逆运算,也就是从激光坐标系 L ′ L' L到世界坐标系 W W W的变换 ( R Z X Y ) − 1 (R_{ZXY})^{-1} (RZXY)1
根据变换的关系很容易得到,所有的变换角度取数,顺序取就可以得到
( R Z X Y ) − 1 = R − r z R − r x R − r y (R_{ZXY})^{-1}=R_{-rz}R_{-rx}R_{-ry} (RZXY)1=RrzRrxRry

单独考虑先Y再X后Z左乘,旋转坐标轴的矩阵 R Y X Z R_{YXZ} RYXZ,有
根据乘旋转坐标轴矩阵
R Y X Z = [ c r z s r z 0 − s r z c r z 0 0 0 1 ] [ 1 0 0 0 c r x s r x 0 − s r x c r x ] [ c r y 0 − s r y 0 1 0 s r y 0 c r y ] R_{YXZ}= \begin{bmatrix} crz& srz &0 \\ -srz & crz& 0 \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} 1& 0 &0 \\ 0 & crx & srx \\ 0 & -srx & crx \end{bmatrix} \begin{bmatrix} cry& 0 &-sry \\ 0 & 1 & 0 \\ sry & 0 & cry \end{bmatrix} RYXZ=crzsrz0srzcrz00011000crxsrx0srxcrxcry0sry010sry0cry
进一步得到
R Y X Z = [ c y c z + s x s y s z c x s z − s y c z + s x c y s z − c y s z + s x s y c z c x c z s y s z + s x c y c z c x s y − s x c x c y ] R_{YXZ}= \begin{bmatrix} cycz+sxsysz& cxsz &-sycz+sxcysz \\ -cysz+sxsycz & cxcz& sysz+sxcycz \\ cxsy & -sx & cxcy \end{bmatrix} RYXZ=cycz+sxsyszcysz+sxsyczcxsycxszcxczsxsycz+sxcyszsysz+sxcyczcxcy

很容易看到单独的 R Y X Z R_{YXZ} RYXZ ( R Z X Y ) − 1 (R_{ZXY})^{-1} (RZXY)1差了一个负号,这是因为逆运算导致的。

上面的乘,对应乘,对右乘有 ( R Z X Y ) = R r z R r x R r y (R_{ZXY})=R_{rz}R_{rx}R_{ry} (RZXY)=RrzRrxRry,这里不考虑。
上面的旋转坐标轴,对应旋转向量,对旋转向量有
R r y = [ c r y 0 s r y 0 1 0 − s r y 0 c r y ] R_{ry}=\begin{bmatrix} cry& 0 &sry \\ 0 & 1 & 0 \\ -sry & 0 & cry \end{bmatrix} Rry=cry0sry010sry0cry
其实就是旋转坐标轴的角度取,可以认为是一组运算。相对运动,哈哈。这个在代码里体现为所有的rotateZXY和rotateYXZ函数都使用的是旋转向量矩阵,因此需要注意实际传入的角度信息。

同样的,对于变换
X t = R X 0 + T X_t=RX_0+T Xt=RX0+T的逆运算为
X 0 = R − 1 ( X t − T ) X_0=R^{-1}(X_t-T) X0=R1(XtT)
这个后面具体用到的时候再说吧。

4. 插话

现在就开始进行我们的源代码阅读之旅,注意分析内容有可能较多,后面视情况再将内容划分。
总体概览和框架可以参考原始论文分析LOAM SLAM之论文原理解读,这里主要对代码进行详细分析。

5. 之MultiScanRegistration

MultiScanRegistration节点利用插值得到的IMU数据对激光运动补偿,得到近似匀速运动模型下的点云数据,同时对点云数据提取边缘特征,平面特征,供LaserOdometry节点使用。

5.1 参数配置

可设置参数以及默认参数有,第一列表示参数名称,第二列表示默认值或者可选值

      scanPeriod                 const float& scanPeriod_ = 0.1,//一次完整的激光数据,对应扫描频率10hzimuHistorySize             const int& imuHistorySize_ = 200,//IMU数据缓冲区大小,后面使用缓冲区数据进行插值featureRegions             const int& nFeatureRegions_ = 6,//同一激光束点云数据划分的子区域数量curvatureRegion            const int& curvatureRegion_ = 5,//计算每个点弯曲度时在当前点一边选取的附近点数,总点数为 region*2maxCornerSharp             const int& maxCornerSharp_ = 2,//子区域中选取的最大边缘点数maxCornerLessSharp         maxCornerLessSharp(10 * maxCornerSharp_)//子区域中选取的最大次边缘点数(弯曲度大于阈值的部分点)maxSurfaceFlat             const int& maxSurfaceFlat_ = 4,//子区域中选取的最大平面点数lessFlatFilterSize         const float& lessFlatFilterSize_ = 0.2,//次平面点滤波器使用的体素大小surfaceCurvatureThreshold  const float& surfaceCurvatureThreshold_ = 0.1//区分平面、边缘时使用的弯曲度阈值//激光设置可以选择下面的一种方式1.          lidar          VLP-16,HDL-32,HDL-64E       //velodyne具体产品,对应16线,32线,64线2.          minVerticalAngle || maxVerticalAngle || nScanRings  //非velodyne产品,根据具体的参数进行设置,并且至少需要2线激光雷达

激光束数量不同的激光雷达(16,32,64线等)在处理数据时稍有不同:需要将不同激光束点云数据区分开,供后续里程计,建图时使用。
因此做了下面工作:

  • 默认支持VLP16,HDL32,HDL64E,创建scanMapper对象。
  • 或者根据最大、最小垂直角度,激光线束构建创建scanMapper对象,这种可以用于非Velodyne品牌的激光雷达。
  if (privateNode.getParam("lidar", lidarName)) {if (lidarName == "VLP-16") {_scanMapper = MultiScanMapper::Velodyne_VLP_16();} else if (lidarName == "HDL-32") {_scanMapper = MultiScanMapper::Velodyne_HDL_32();} else if (lidarName == "HDL-64E") {_scanMapper = MultiScanMapper::Velodyne_HDL_64E();} else {ROS_ERROR("Invalid lidar parameter: %s (only \"VLP-16\", \"HDL-32\" and \"HDL-64E\" are supported)", lidarName.c_str());return false;}ROS_INFO("Set  %s  scan mapper.", lidarName.c_str());if (!privateNode.hasParam("scanPeriod")) {config_out.scanPeriod = 0.1;ROS_INFO("Set scanPeriod: %f", config_out.scanPeriod);}} else {float vAngleMin, vAngleMax;int nScanRings;if (privateNode.getParam("minVerticalAngle", vAngleMin) &&privateNode.getParam("maxVerticalAngle", vAngleMax) &&privateNode.getParam("nScanRings", nScanRings)) {if (vAngleMin >= vAngleMax) {ROS_ERROR("Invalid vertical range (min >= max)");return false;} else if (nScanRings < 2) {ROS_ERROR("Invalid number of scan rings (n < 2)");return false;}_scanMapper.set(vAngleMin, vAngleMax, nScanRings);ROS_INFO("Set linear scan mapper from %g to %g degrees with %d scan rings.", vAngleMin, vAngleMax, nScanRings);}}

scanMapper对象的作用使用一句话概括:根据角度获取激光束ID,后续用于分开不同激光束数据。

  • 以vlp16为例
  /** \brief Construct a new multi scan mapper instance.** @param lowerBound - the lower vertical bound (degrees)* @param upperBound - the upper vertical bound (degrees)* @param nScanRings - the number of scan rings*/MultiScanMapper(const float& lowerBound = -15,const float& upperBound = 15,const uint16_t& nScanRings = 16);MultiScanMapper::MultiScanMapper(const float& lowerBound,const float& upperBound,const uint16_t& nScanRings): _lowerBound(lowerBound),_upperBound(upperBound),_nScanRings(nScanRings),_factor((nScanRings - 1) / (upperBound - lowerBound))
{}
// 根据垂直角度获取激光束ID:0,1,2、、、15
int MultiScanMapper::getRingForAngle(const float& angle) {return int(((angle * 180 / M_PI) - _lowerBound) * _factor + 0.5);
}

5.2 话题订阅

bool ScanRegistration::setupROS(ros::NodeHandle& node, ros::NodeHandle& privateNode, RegistrationParams& config_out)
{if (!parseParams(privateNode, config_out))return false;// subscribe to IMU topic_subImu = node.subscribe<sensor_msgs::Imu>("/imu/data", 50, &ScanRegistration::handleIMUMessage, this);// advertise scan registration topics_pubLaserCloud            = node.advertise<sensor_msgs::PointCloud2>("/velodyne_cloud_2", 2);_pubCornerPointsSharp     = node.advertise<sensor_msgs::PointCloud2>("/laser_cloud_sharp", 2);_pubCornerPointsLessSharp = node.advertise<sensor_msgs::PointCloud2>("/laser_cloud_less_sharp", 2);_pubSurfPointsFlat        = node.advertise<sensor_msgs::PointCloud2>("/laser_cloud_flat", 2);_pubSurfPointsLessFlat    = node.advertise<sensor_msgs::PointCloud2>("/laser_cloud_less_flat", 2);_pubImuTrans              = node.advertise<sensor_msgs::PointCloud2>("/imu_trans", 5);return true;
}

以及点云数据

  // subscribe to input cloud topic_subLaserCloud = node.subscribe<sensor_msgs::PointCloud2>("/multi_scan_points", 2, &MultiScanRegistration::handleCloudMessage, this);

5.3 IMU数据处理

IMU数据的处理如下,IMU的坐标系为前左上,对测量的比力进行处理得到真正的加速度信号,可以自己推导一下,有需要的话我再把这部分内容添加上,转换坐标系为左上前坐标系。这里的偏航俯仰滚转角三个欧拉角都不会变化,可以考虑一下为什么。

void ScanRegistration::handleIMUMessage(const sensor_msgs::Imu::ConstPtr& imuIn)
{tf::Quaternion orientation;tf::quaternionMsgToTF(imuIn->orientation, orientation);double roll, pitch, yaw;tf::Matrix3x3(orientation).getRPY(roll, pitch, yaw);Vector3 acc;acc.x() = float(imuIn->linear_acceleration.y - sin(roll) * cos(pitch) * 9.81);acc.y() = float(imuIn->linear_acceleration.z - cos(roll) * cos(pitch) * 9.81);acc.z() = float(imuIn->linear_acceleration.x + sin(pitch)             * 9.81);IMUState newState;newState.stamp = fromROSTime( imuIn->header.stamp);newState.roll = roll;newState.pitch = pitch;newState.yaw = yaw;newState.acceleration = acc;updateIMUData(acc, newState);
}

对于陀螺仪而言,测量得到加速度信息是需要进行积分的,因此有
加速度信号转换到世界坐标系然后积分得到速度velocity,再积分得到位置position
对于姿态角,不必积分,直接使用即可。
将这些数据存储到IMU环形容器_imuHistory中。用于后面的插值以及偏移计算

void BasicScanRegistration::updateIMUData(Vector3& acc, IMUState& newState)
{if (_imuHistory.size() > 0) {// accumulate IMU position and velocity over time//这里将数据转换到世界坐标系中,理论上应该取负数,但是这里代码中使用了旋转向量矩阵,因此就不需要取负数了rotateZXY(acc, newState.roll, newState.pitch, newState.yaw);const IMUState& prevState = _imuHistory.last();float timeDiff = toSec(newState.stamp - prevState.stamp);newState.position = prevState.position+ (prevState.velocity * timeDiff)+ (0.5 * acc * timeDiff * timeDiff);newState.velocity = prevState.velocity+ acc * timeDiff;}_imuHistory.push(newState);
}

觉得太复杂直接记住下面的总结就行,我就不多讲了,容易混淆。简单总结就是对于IMU测量的姿态角有:

        rotateYXZ(-yaw,-pitch,-roll)世界坐标系------------>>>>>IMU坐标系世界坐标系<<<<<------------IMU坐标系rotateZXY(roll,pitch,yaw)

5.4 点云数据处理

5.4.1 点云数据

整个点云注册环节核心部分就在这里,主要是对点云数据进一步处理,根据IMU数据校正得到近似匀速运动的点云数据,以供激光里程计使用。对,匀速运动假设,这个很重要,需要构造这样的条件,不然后面的处理会出现很大的误差。

具体的处理环节在下面的函数中

void MultiScanRegistration::process(const pcl::PointCloud<pcl::PointXYZ>& laserCloudIn, const Time& scanTime)

原始的激光坐标系是右前上时针旋转,点云数据的水平角度递减,但是可以通过安装将激光坐标系变换为前左上,依旧是时针旋转,点云数据水平角度递减,很简单对不对。这样就可以很方便的转换为论文中提到的统一坐标系左上前
在这里插入图片描述
判断点的起始方位和终止方位,原始角度是顺时针变化,因此需要对角度进行取,并对角度判断周期性。注意负号,因为原始是时针旋转的,这里取负号,后面也取负号,就统一变为时针旋转。

  • atan2的值域是 − π -\pi π π \pi π
  float startOri = -std::atan2(laserCloudIn[0].y, laserCloudIn[0].x);float endOri = -std::atan2(laserCloudIn[cloudSize - 1].y,laserCloudIn[cloudSize - 1].x) + 2 * float(M_PI);// 必须满足终止角度 > 初始角度并且,近似一个周期2piif (endOri - startOri > 3 * M_PI) {endOri -= 2 * M_PI;} else if (endOri - startOri < M_PI) {endOri += 2 * M_PI;}

需要转换坐标系:前左上转换为左上前坐标系,不处理无效点数据。

    // 前左上 转换 为左上前坐标系point.x = laserCloudIn[i].y;point.y = laserCloudIn[i].z;point.z = laserCloudIn[i].x;// skip NaN and INF valued points// 去除无效点数据if (!pcl_isfinite(point.x) ||!pcl_isfinite(point.y) ||!pcl_isfinite(point.z)) {continue;}// skip zero valued pointsif (point.x * point.x + point.y * point.y + point.z * point.z < 0.0001) {continue;}

根据垂直角度找到对应的激光束ID,这个时候坐标系已经发生了改变,注意使用的坐标轴数据

    // calculate vertical point angle and scan IDfloat angle = std::atan(point.y / std::sqrt(point.x * point.x + point.z * point.z));int scanID = _scanMapper.getRingForAngle(angle);if (scanID >= _scanMapper.getNumberOfScanRings() || scanID < 0 ){continue;}

接下来是求解每个点的水平角度,根据水平角度可以得到获取每个点时相对与开始点的时间,因为激光雷达是先垂直扫描再水平扫描的。但是角度函数atan2的值域是 − π -\pi π π \pi π,因此必须要进行变换。这里将整个水平方向(360度)扫描的点分为两部分,第一部分与开始点起始角度startOri相差角度在 π \pi π以内,第二部分与结束点终止角度endOri相差角度在 π \pi π以内。这里默认使用的角度是逆时针增加

求解每个点的水平角度,对于atan2的结果需要取负数,以和前面对应,将顺时针递减的角度转变为逆时针递增的顺序。
注意实际中的激光雷达数据不一定从角度0开始的,因此需要考虑到atan2解算的角度在 − π -\pi π π \pi π变换时的突变会出问题,需要对角度进一步补偿

例如相邻点云数据点的角度由atan2解算并取负数后为 π − 0.1 \pi-0.1 π0.1 − π + 0.1 -\pi+0.1 π+0.1。在空间上是相邻的,由于三角函数的关系它们在数值上并不相邻,因此需要进行补偿,主要是对 − π + 0.1 -\pi+0.1 π+0.1补偿。

  • 考虑起始角度startOri为 π / 2 \pi/2 π/2,现在角度是逆时针增加,那么当前角度为 − π + 0.1 -\pi+0.1 π+0.1时会出现 ( − π + 0.1 ) − ( π / 2 ) = − 3 ∗ π / 2 + 0.1 (-\pi+0.1)-(\pi/2) = -3*\pi/2+0.1 (π+0.1)(π/2)=3π/2+0.1。因为 − 3 ∗ π / 2 + 0.1 -3*\pi/2+0.1 3π/2+0.1小于0,这也就意味着角度不是增加了,根据角度周期性 2 π 2\pi 2π,实际角度应该为 − π + 0.1 − − > π + 0.1 -\pi+0.1 --> \pi+0.1 π+0.1>π+0.1。这样才有 ( π + 0.1 ) − ( π / 2 ) = 1 / 2 ∗ π + 0.1 (\pi+0.1)-(\pi/2) = 1/2*\pi + 0.1 (π+0.1)(π/2)=1/2π+0.1,这表示角度增加。因此考虑到个扫描周期并且包含有 π \pi π − π -\pi π转换后有当 θ − s t a r t O r i < − 1 / 2 ∗ π \theta - startOri < -1/2*\pi θstartOri<1/2π时需要进行补偿 θ + 2 π \theta+2\pi θ+2π可以参考图解。
    在这里插入图片描述

  • 上图,绿色线表示起始方位,黑色表示当前点的解算方位,由于atan2的原因,角度会出现突变,导致角度需要进行补偿,可以看到突变的角度为角度减少 2 π 2\pi 2π,但是根据半个周期的约束,如果包含该突变,起始角度必须位于 [ 0 , π ] [0,\pi] [0,π]之间,也就是说当前解算方位和起始方位最大相差 − π -\pi π,同时考虑不与顺时针旋转冲突,起始方位角有偏差。因此实际设定只要相差 − π / 2 -\pi/2 π/2就认为当前解算方位和起始方位还没有相差半个周期角度 π \pi π,需要补偿当前方位角。而当前解算方位和起始方位相差半个周期角度 π \pi π以上时,默认会让解算角度增加 。

  • 同样的,考虑当角度变换顺时针减少时,需要补偿条件为( θ \theta θ) - startOri > 3/2*pi。

其实可以看到应该有两个判断半周期的条件,一个是ori - startOri > M_PI,一个是ori - startOri < -M_PI。但是velodyne只需要使用ori - startOri > M_PI,估计也就没有添加了。
或者说ori > startOri + M_PI * 3 / 2是多余的,没有用处,因为已经保证了角度增加。

同样的可以得到endOri和ori的关系,其实就是一一对应罢了。可以参考下面的不等式:

  • ori < startOri - M_PI / 2
  • ori > endOri + M_PI / 2 --> ori - M_PI / 2> endOri
    // calculate horizontal point angle// atan2的角度,值位于-pi到pi之间float ori = -std::atan2(point.x, point.z);if (!halfPassed) {if (ori < startOri - M_PI / 2) {ori += 2 * M_PI;} else if (ori > startOri + M_PI * 3 / 2) {ori -= 2 * M_PI;}if (ori - startOri > M_PI) {halfPassed = true;}} else {ori += 2 * M_PI;if (ori < endOri - M_PI * 3 / 2) {ori += 2 * M_PI;} else if (ori > endOri + M_PI / 2) {ori -= 2 * M_PI;}}

因此根据水平角度可以得到每个点相对于开始点的相对时间relTime,并存储到intensit中。
对点云数据格式信息的利用达到最大化:把pcl::PointXYZI中的intensity设置为相对开始点的时间对应线束id,一个数据包含了两个信息!!后面还有类似的操作。

// calculate relative scan time based on point orientationfloat relTime = config().scanPeriod * (ori - startOri) / (endOri - startOri);point.intensity = scanID + relTime;

那这个相对时间relTime有什么用呢?马上就会用到:和IMU数据一起近似去除激光的非匀速运动,构建匀速运动模型

5.4.2 近似匀速运动模型

当IMU数据存在时,使用IMU数据积分得到速度位置对点云数据进行补偿,得到激光匀速运动模型下的坐标。
首先是获取IMU数据,使用插值得到精确时间_scanTime + relTime的IMU数据(包含速度,位置,三个姿态角)

// 根据relTime进行IMU数据插值,得到速度,位置信息。
void BasicScanRegistration::interpolateIMUStateFor(const float &relTime, IMUState &outputState)
{// IMU数据和点云点的时间差:点云点时间 - IMU时间double timeDiff = toSec(_scanTime - _imuHistory[_imuIdx].stamp) + relTime;while (_imuIdx < _imuHistory.size() - 1 && timeDiff > 0) {_imuIdx++;// IMU数据和点云点的时间差:点云点时间 - IMU时间timeDiff = toSec(_scanTime - _imuHistory[_imuIdx].stamp) + relTime;}if (_imuIdx == 0 || timeDiff > 0) {// 不能插值,1._imuIdx == 0:使用最旧的数据  2. timeDiff > 0:使用最新的数据outputState = _imuHistory[_imuIdx];} else {// 如果IMU缓冲区中可以插值,就直接插值float ratio = -timeDiff / toSec(_imuHistory[_imuIdx].stamp - _imuHistory[_imuIdx - 1].stamp);IMUState::interpolate(_imuHistory[_imuIdx], _imuHistory[_imuIdx - 1], ratio, outputState);}
}

进行插值时特别注意偏航角,这里和刚才有点类似, − π -\pi π π \pi π的转换,这是因为一般偏航角度可以360度,必须考虑角度的转换,而俯仰、滚转角度变换较小,不会存在 − π -\pi π π \pi π的转换。

    /** \brief Interpolate between two IMU states.** @param start the first IMUState* @param end the second IMUState* @param ratio the interpolation ratio* @param result the target IMUState for storing the interpolation result*/static void interpolate(const IMUState& start,const IMUState& end,const float& ratio,IMUState& result){float invRatio = 1 - ratio;result.roll = start.roll.rad() * invRatio + end.roll.rad() * ratio;result.pitch = start.pitch.rad() * invRatio + end.pitch.rad() * ratio;if (start.yaw.rad() - end.yaw.rad() > M_PI){result.yaw = start.yaw.rad() * invRatio + (end.yaw.rad() + 2 * M_PI) * ratio;}else if (start.yaw.rad() - end.yaw.rad() < -M_PI){result.yaw = start.yaw.rad() * invRatio + (end.yaw.rad() - 2 * M_PI) * ratio;}else{result.yaw = start.yaw.rad() * invRatio + end.yaw.rad() * ratio;}result.velocity = start.velocity * invRatio + end.velocity * ratio;result.position = start.position * invRatio + end.position * ratio;};

经过上面处理,因此我们拥有了当前点时间(_scanTime + relTime)对应的激光位置和速度,
进一步和开始点(_scanTime + 0)的激光位置、速度进行处理有
Startposition + PositionShift + 匀速模型(开始点对应的速度) = Curposition。可以考虑激光加速运动下,PositionShift表示了开始点时刻到当前时刻激光由于加速移动的距离

  • relSweepTime其实等于relTime,表示当前点相对于开始点的时间差。
  // 得到非匀速运动下的激光位置偏移量。 Startposition + PositionShift + 匀速模型(开始点对应的速度) = Curpositionfloat relSweepTime = toSec(_scanTime - _sweepStart) + relTime;// _scanTime - _sweepStart = 0_imuPositionShift = _imuCur.position - _imuStart.position - _imuStart.velocity * relSweepTime;

对于位置偏移,需要在世界坐标系下对点云数据进行处理,这里使用了之前提到的IMU局部坐标系和世界坐标系的转换,并且由于点云测量坐标和激光运动趋势相反,因此这里需要上位置偏移量,特别注意不是减!然后进一步得到IMU局部坐标系下的点云坐标。

void BasicScanRegistration::transformToStartIMU(pcl::PointXYZI& point)
{// rotate point to global IMU system// IMU局部坐标系到世界坐标系转换rotateZXY(point, _imuCur.roll, _imuCur.pitch, _imuCur.yaw);// add global IMU position shift// 加上位置偏移量,这里 加 是因为激光运动和点云运动是相反的。// 可以考虑加速运动下,PositionShift为正,并且点云测量坐标系变小,因此需要让点云坐标变大。point.x += _imuPositionShift.x();point.y += _imuPositionShift.y();point.z += _imuPositionShift.z();// rotate point back to local IMU system relative to the start IMU state// 世界坐标系到IMU局部坐标系转换rotateYXZ(point, -_imuStart.yaw, -_imuStart.pitch, -_imuStart.roll);
}

然后将近似匀速运动模型下IMU局部坐标系下的点云数据进行存储得到

_laserCloudScans[scanID].push_back(point);

5.4.3 特征提取

对所有点云数据进行运动补偿之后就是进行特征点提取了。首先变换点云数据存储格式,得到一维状态下的点云数据。使用_scanIndices来确定每束数据的开始位置,结束位置。

 pcl::PointCloud<pcl::PointXYZI> _laserCloud;std::vector<pcl::PointCloud<pcl::PointXYZI> > _laserCloudScans;
void BasicScanRegistration::processScanlines(const Time& scanTime, std::vector<pcl::PointCloud<pcl::PointXYZI>> const& laserCloudScans)
{// reset internal buffers and set IMU start state based on current scan time// 为下一次处理做准备,同时清空当前特征点容器reset(scanTime);  // construct sorted full resolution cloud// 将不同激光束点云数据合并为一维形式,使用_scanIndices来确定每束数据的开始位置,结束位置。size_t cloudSize = 0;for (int i = 0; i < laserCloudScans.size(); i++) {_laserCloud += laserCloudScans[i];IndexRange range(cloudSize, 0);cloudSize += laserCloudScans[i].size();range.second = cloudSize > 0 ? cloudSize - 1 : 0;_scanIndices.push_back(range);}extractFeatures();updateIMUTransform();
}

现在最重要的环节就是提取每个激光束的特征点,包含平面特征点和边缘特征点,具体可以参考下面
特征点的选择以及注意事项可以参考论文理解部分,这里不过多讲解。在

void BasicScanRegistration::extractFeatures(const uint16_t& beginIdx)

中进行特征点提取。
提取每个激光束点云数据在一维容器中的的开始索引,结束索引,并且保证点云数据的数量足够能够进行弯曲度计算。

    // 每个激光束点云数据在一维容器中的的开始索引,结束索引size_t scanStartIdx = _scanIndices[i].first;size_t scanEndIdx = _scanIndices[i].second;// skip empty scans// 激光束中数据不够if (scanEndIdx <= scanStartIdx + 2 * _config.curvatureRegion) {continue;}

对不不可靠点进行标记,0表示未被选择,可以作为特征点,1表示不能被选择为特征点。

  • 不考虑每次激光束的curvatureRegion,最后curvatureRegion个点,因为不能计算弯曲度。
  • 相邻点距离过大,说明可能不在同一个平面上。加权后的点近似相邻,但是空间中不相邻,因此存在被遮挡的可能,平面过于倾斜的可能,属于不可靠点,需要标记,不能作为特征点。注意标记是对应标记的区域是根据点的远近来决定的
  • 考虑A,B,C相邻三个点,AB距离,BC距离均大于B到激光距离一定比例,表明B点可能为离群点,位于过于倾斜的平面,不可靠的测量数据。
void BasicScanRegistration::setScanBuffersFor(const size_t& startIdx, const size_t& endIdx)
{// resize bufferssize_t scanSize = endIdx - startIdx + 1;// 0表示未被选择,可以作为特征点_scanNeighborPicked.assign(scanSize, 0);// mark unreliable points as picked// 不考虑每次激光束的前curvatureRegion,最后curvatureRegion个点,因为不能计算弯曲度for (size_t i = startIdx + _config.curvatureRegion; i < endIdx - _config.curvatureRegion; i++) {const pcl::PointXYZI& previousPoint = (_laserCloud[i - 1]);const pcl::PointXYZI& point = (_laserCloud[i]);const pcl::PointXYZI& nextPoint = (_laserCloud[i + 1]);float diffNext = calcSquaredDiff(nextPoint, point);// 相邻点距离过大,说明可能不在同一个平面上,需要考虑if (diffNext > 0.1) {float depth1 = calcPointDistance(point);float depth2 = calcPointDistance(nextPoint);// 将距离远的点 坐标比例缩放重投影 到 距离近的点平面if (depth1 > depth2) {// 加权下的相邻点距离  point点较远float weighted_distance = std::sqrt(calcSquaredDiff(nextPoint, point, depth2 / depth1)) / depth2;// 这里加权距离小说明,加权后的点近似相邻,但是空间中不相邻,因此存在被遮挡的可能,平面过于倾斜的可能,// 属于不可靠点,需要标记,不能作为特征点// point远,那么point左边这一部分区域点都不能作为特征点if (weighted_distance < 0.1) {std::fill_n(&_scanNeighborPicked[i - startIdx - _config.curvatureRegion], _config.curvatureRegion + 1, 1);continue;}} else {// 和上面的分析类似float weighted_distance = std::sqrt(calcSquaredDiff(point, nextPoint, depth1 / depth2)) / depth1;// nextPoint远,那么nextPoint右边这一部分区域点都不能作为特征点if (weighted_distance < 0.1) {std::fill_n(&_scanNeighborPicked[i - startIdx + 1], _config.curvatureRegion + 1, 1);}}}float diffPrevious = calcSquaredDiff(point, previousPoint);float dis = calcSquaredPointDistance(point);// 考虑A,B,C相邻三个点,AB距离,BC距离均大于B到激光距离一定比例,表明B点可能为离群点,位于过于倾斜的平面,不可靠的测量数据,if (diffNext > 0.0002 * dis && diffPrevious > 0.0002 * dis) {_scanNeighborPicked[i - startIdx] = 1;}}
}

将同一激光束索引划分子区域,得到子区域索引sp,ep,

      // 将同一激光束索引划分子区域,得到子区域索引sp epsize_t sp = ((scanStartIdx + _config.curvatureRegion) * (_config.nFeatureRegions - j)+ (scanEndIdx - _config.curvatureRegion) * j) / _config.nFeatureRegions;size_t ep = ((scanStartIdx + _config.curvatureRegion) * (_config.nFeatureRegions - 1 - j)+ (scanEndIdx - _config.curvatureRegion) * (j + 1)) / _config.nFeatureRegions - 1;// skip empty regions// 跳过空的子区域if (ep <= sp) {continue;}

根据论文中向量的归一化模值公式计算子区域中每个点的弯曲度并进行排序,弯曲度越,对应索引存储位置越靠。初始所有点的特征标志为次平面点。特征点的种类存放在_regionLabel中(边缘点,次边缘点,平面点,次平面点)

void BasicScanRegistration::setRegionBuffersFor(const size_t& startIdx, const size_t& endIdx)
{// resize bufferssize_t regionSize = endIdx - startIdx + 1;_regionCurvature.resize(regionSize);_regionSortIndices.resize(regionSize);// 初始所有点的标志为 次平面点_regionLabel.assign(regionSize, SURFACE_LESS_FLAT);// calculate point curvatures and reset sort indicesfloat pointWeight = -2 * _config.curvatureRegion;// 根据论文中 向量的归一化模值 计算每个点的弯曲度并存储for (size_t i = startIdx, regionIdx = 0; i <= endIdx; i++, regionIdx++) {float diffX = pointWeight * _laserCloud[i].x;float diffY = pointWeight * _laserCloud[i].y;float diffZ = pointWeight * _laserCloud[i].z;for (int j = 1; j <= _config.curvatureRegion; j++) {diffX += _laserCloud[i + j].x + _laserCloud[i - j].x;diffY += _laserCloud[i + j].y + _laserCloud[i - j].y;diffZ += _laserCloud[i + j].z + _laserCloud[i - j].z;}_regionCurvature[regionIdx] = diffX * diffX + diffY * diffY + diffZ * diffZ;_regionSortIndices[regionIdx] = i;}// sort point curvatures// 根据弯曲度将索引排序,弯曲度越大,对应索引存储位置越靠后for (size_t i = 1; i < regionSize; i++) {for (size_t j = i; j >= 1; j--) {if (_regionCurvature[_regionSortIndices[j] - startIdx] < _regionCurvature[_regionSortIndices[j - 1] - startIdx]) {std::swap(_regionSortIndices[j], _regionSortIndices[j - 1]);}}}
}

然后根据弯曲度选取边缘点,具体操作如下

  • 根据弯曲度大小提取边缘点,弯曲度越大越可能为边缘点,因此从后面开始寻找
  • 未被标记 + 弯曲度满足阈值 + 边缘点最大数量未满足 --> 可被选为边缘点
  • 边缘点,次边缘点附近左右curvatureRegion区域点进行标记
      // extract corner featuresint largestPickedNum = 0;// 根据弯曲度大小提取边缘点,弯曲度越大越可能为边缘点,因此从后面开始寻找for (size_t k = regionSize; k > 0 && largestPickedNum < _config.maxCornerLessSharp;) {// idx为_laserCloud中的索引  用于提取点云数据// scanIdx为_scanNeighborPicked中的索引   用于标记能够作为特征点// regionIdx为_regionCurvature,_regionLabel中的索引  用于提取弯曲度,标识特征类型size_t idx = _regionSortIndices[--k];size_t scanIdx = idx - scanStartIdx;size_t regionIdx = idx - sp;// 未被标记 + 弯曲度满足阈值 + 边缘点最大数量未满足 --> 可被选为边缘点// 边缘点,次边缘点附近左右curvatureRegion区域点进行标记if (_scanNeighborPicked[scanIdx] == 0 &&_regionCurvature[regionIdx] > _config.surfaceCurvatureThreshold) {largestPickedNum++;// 数量为达到则为边缘点CORNER_SHARP,否则为次边缘点CORNER_LESS_SHARPif (largestPickedNum <= _config.maxCornerSharp) {_regionLabel[regionIdx] = CORNER_SHARP;// 只存放边缘点_cornerPointsSharp.push_back(_laserCloud[idx]);} else {_regionLabel[regionIdx] = CORNER_LESS_SHARP;}// 存放边缘点和次边缘点_cornerPointsLessSharp.push_back(_laserCloud[idx]);// 对选取点附近左右curvatureRegion区域点进行标记markAsPicked(idx, scanIdx);}}

对平面特征点同样有

  • 根据弯曲度大小提取平面点,弯曲度越小越可能为边缘点,因此从前面开始寻找
  • 未被标记 + 弯曲度满足阈值 + 边缘点最大值未满足 --> 可被选为平面点
  • 平面点附近左右curvatureRegion区域点进行标记
     // extract flat surface featuresint smallestPickedNum = 0;// 根据弯曲度大小提取平面点,弯曲度越小越可能为边缘点,因此从前面开始寻找for (int k = 0; k < regionSize && smallestPickedNum < _config.maxSurfaceFlat; k++) {// idx为_laserCloud中的索引  用于提取点云数据// scanIdx为_scanNeighborPicked中的索引   用于标记能够作为特征点// regionIdx为_regionCurvature,_regionLabel中的索引  用于提取弯曲度,标识特征类型size_t idx = _regionSortIndices[k];size_t scanIdx = idx - scanStartIdx;size_t regionIdx = idx - sp;// 未被标记 + 弯曲度满足阈值 + 边缘点最大值未满足 --> 可被选为平面点// 平面点点附近左右curvatureRegion区域点进行标记if (_scanNeighborPicked[scanIdx] == 0 &&_regionCurvature[regionIdx] < _config.surfaceCurvatureThreshold) {smallestPickedNum++;_regionLabel[regionIdx] = SURFACE_FLAT;// 只存放平面点_surfacePointsFlat.push_back(_laserCloud[idx]);markAsPicked(idx, scanIdx);}}// extract less flat surface features// 存放所有平面点,次平面点。也就是非边缘点,次边缘点的所有点for (int k = 0; k < regionSize; k++) {if (_regionLabel[k] <= SURFACE_LESS_FLAT) {surfPointsLessFlatScan->push_back(_laserCloud[sp + k]);}}

最后对所有的次平面点进行降采样

    // down size less flat surface point cloud of current scan// 对次平面点进行降采样得到_surfacePointsLessFlatpcl::PointCloud<pcl::PointXYZI> surfPointsLessFlatScanDS;pcl::VoxelGrid<pcl::PointXYZI> downSizeFilter;downSizeFilter.setInputCloud(surfPointsLessFlatScan);downSizeFilter.setLeafSize(_config.lessFlatFilterSize, _config.lessFlatFilterSize, _config.lessFlatFilterSize);downSizeFilter.filter(surfPointsLessFlatScanDS);_surfacePointsLessFlat += surfPointsLessFlatScanDS;

到此,所有的特征点存放在_cornerPointsSharp_cornerPointsLessSharp_surfacePointsFlat_surfacePointsLessFlat(经过滤波)中。

5.4.4 打包IMU数据

这个时候需要对IMU变换数据进行存储打包,把imu信息存储在点云格式的数据中,在激光里程计中还会加以使用。

  • 获取点云中第一个点时对应的激光姿态角_imuStart,获取点云中最后一个点时对应的激光姿态角_imuCur
  • 获取点云中最后一个点时激光相对于获取开始点时的激光的 非匀速运动 位置偏移 开始点对应的IMU局部坐标系imuShiftFromStart
  • 获取点云中最后一个点时激光相对于获取开始点时的激光的 非匀速运动 速度偏移 开始点对应的IMU局部坐标系imuVelocityFromStart
  • 点云已经去除了非匀速运动,为什么还需要呢?因为后面估计的是激光运动,不是点云运动,因此必须要在激光里程计中恢复原来的非匀速运动偏移
void BasicScanRegistration::updateIMUTransform()
{// 更新IMU变换的数据,可以供激光里程计中使用// 获取点云中第一个点时对应的激光姿态角_imuTrans[0].x = _imuStart.pitch.rad();_imuTrans[0].y = _imuStart.yaw.rad();_imuTrans[0].z = _imuStart.roll.rad();// 获取点云中最后一个点时对应的激光姿态角_imuTrans[1].x = _imuCur.pitch.rad();_imuTrans[1].y = _imuCur.yaw.rad();_imuTrans[1].z = _imuCur.roll.rad();Vector3 imuShiftFromStart = _imuPositionShift;// 世界坐标系到IMU局部坐标系转换rotateYXZ(imuShiftFromStart, -_imuStart.yaw, -_imuStart.pitch, -_imuStart.roll);// 获取点云中最后一个点时激光相对于获取开始点时的激光的 非匀速运动 位置偏移 开始点对应的IMU局部坐标系_imuTrans[2].x = imuShiftFromStart.x();_imuTrans[2].y = imuShiftFromStart.y();_imuTrans[2].z = imuShiftFromStart.z();Vector3 imuVelocityFromStart = _imuCur.velocity - _imuStart.velocity;// 世界坐标系到IMU局部坐标系转换rotateYXZ(imuVelocityFromStart, -_imuStart.yaw, -_imuStart.pitch, -_imuStart.roll);// 获取点云中最后一个点时激光相对于获取开始点时的激光的 非匀速运动 速度偏移 开始点对应的IMU局部坐标系_imuTrans[3].x = imuVelocityFromStart.x();_imuTrans[3].y = imuVelocityFromStart.y();_imuTrans[3].z = imuVelocityFromStart.z();
}

5.5发布数据

最后将得到点云数据以及IMU数据进行发布。_pubLaserCloud近似匀速模型下所有点云数据。其他的前面已经涉及到了,就不再赘述。

void ScanRegistration::publishResult()
{auto sweepStartTime = toROSTime(sweepStart());// publish full resolution and feature point clouds// 发布点云数据,特征点云数据publishCloudMsg(_pubLaserCloud, laserCloud(), sweepStartTime, "/camera");publishCloudMsg(_pubCornerPointsSharp, cornerPointsSharp(), sweepStartTime, "/camera");publishCloudMsg(_pubCornerPointsLessSharp, cornerPointsLessSharp(), sweepStartTime, "/camera");publishCloudMsg(_pubSurfPointsFlat, surfacePointsFlat(), sweepStartTime, "/camera");publishCloudMsg(_pubSurfPointsLessFlat, surfacePointsLessFlat(), sweepStartTime, "/camera");// publish corresponding IMU transformation information// 发布IMU测量的激光位姿变换数据publishCloudMsg(_pubImuTrans, imuTransform(), sweepStartTime, "/camera");
}

更新日志

同步更新地址知乎:LOAM SLAM原理之源代码分析附公式推导
有细节性出入!!主要是细节部分的解疑,问题讨论部分,建议前往知乎阅读,此博客只是原始版本(还不是因为大家讨论得太少了,修改都在知乎上进行了 )

  • 2020-12-07
    整理完成LaserOdometry节点内容,地址请转到LOAM源代码分析附公式推导之LaserOdometry
    最近有点忙,因此这个时间跨度有点大,两个月了,我自己都没有想到!害,很多公式自己有重新推导了一次,希望能够帮助大家!
  • 2020-10-11
    整理完成MultiScanRegistration节点内容
  • 2020-10-09
    第一次编辑,初版

这篇关于LOAM源代码分析附公式推导之MultiScanRegistration的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/258081

相关文章

性能分析之MySQL索引实战案例

文章目录 一、前言二、准备三、MySQL索引优化四、MySQL 索引知识回顾五、总结 一、前言 在上一讲性能工具之 JProfiler 简单登录案例分析实战中已经发现SQL没有建立索引问题,本文将一起从代码层去分析为什么没有建立索引? 开源ERP项目地址:https://gitee.com/jishenghua/JSH_ERP 二、准备 打开IDEA找到登录请求资源路径位置

uva 10014 Simple calculations(数学推导)

直接按照题意来推导最后的结果就行了。 开始的时候只做到了第一个推导,第二次没有继续下去。 代码: #include<stdio.h>int main(){int T, n, i;double a, aa, sum, temp, ans;scanf("%d", &T);while(T--){scanf("%d", &n);scanf("%lf", &first);scanf

SWAP作物生长模型安装教程、数据制备、敏感性分析、气候变化影响、R模型敏感性分析与贝叶斯优化、Fortran源代码分析、气候数据降尺度与变化影响分析

查看原文>>>全流程SWAP农业模型数据制备、敏感性分析及气候变化影响实践技术应用 SWAP模型是由荷兰瓦赫宁根大学开发的先进农作物模型,它综合考虑了土壤-水分-大气以及植被间的相互作用;是一种描述作物生长过程的一种机理性作物生长模型。它不但运用Richard方程,使其能够精确的模拟土壤中水分的运动,而且耦合了WOFOST作物模型使作物的生长描述更为科学。 本文让更多的科研人员和农业工作者

MOLE 2.5 分析分子通道和孔隙

软件介绍 生物大分子通道和孔隙在生物学中发挥着重要作用,例如在分子识别和酶底物特异性方面。 我们介绍了一种名为 MOLE 2.5 的高级软件工具,该工具旨在分析分子通道和孔隙。 与其他可用软件工具的基准测试表明,MOLE 2.5 相比更快、更强大、功能更丰富。作为一项新功能,MOLE 2.5 可以估算已识别通道的物理化学性质。 软件下载 https://pan.quark.cn/s/57

hdu 4565 推倒公式+矩阵快速幂

题意 求下式的值: Sn=⌈ (a+b√)n⌉%m S_n = \lceil\ (a + \sqrt{b}) ^ n \rceil\% m 其中: 0<a,m<215 0< a, m < 2^{15} 0<b,n<231 0 < b, n < 2^{31} (a−1)2<b<a2 (a-1)^2< b < a^2 解析 令: An=(a+b√)n A_n = (a +

衡石分析平台使用手册-单机安装及启动

单机安装及启动​ 本文讲述如何在单机环境下进行 HENGSHI SENSE 安装的操作过程。 在安装前请确认网络环境,如果是隔离环境,无法连接互联网时,请先按照 离线环境安装依赖的指导进行依赖包的安装,然后按照本文的指导继续操作。如果网络环境可以连接互联网,请直接按照本文的指导进行安装。 准备工作​ 请参考安装环境文档准备安装环境。 配置用户与安装目录。 在操作前请检查您是否有 sud

线性因子模型 - 独立分量分析(ICA)篇

序言 线性因子模型是数据分析与机器学习中的一类重要模型,它们通过引入潜变量( latent variables \text{latent variables} latent variables)来更好地表征数据。其中,独立分量分析( ICA \text{ICA} ICA)作为线性因子模型的一种,以其独特的视角和广泛的应用领域而备受关注。 ICA \text{ICA} ICA旨在将观察到的复杂信号

【软考】希尔排序算法分析

目录 1. c代码2. 运行截图3. 运行解析 1. c代码 #include <stdio.h>#include <stdlib.h> void shellSort(int data[], int n){// 划分的数组,例如8个数则为[4, 2, 1]int *delta;int k;// i控制delta的轮次int i;// 临时变量,换值int temp;in

三相直流无刷电机(BLDC)控制算法实现:BLDC有感启动算法思路分析

一枚从事路径规划算法、运动控制算法、BLDC/FOC电机控制算法、工控、物联网工程师,爱吃土豆。如有需要技术交流或者需要方案帮助、需求:以下为联系方式—V 方案1:通过霍尔传感器IO中断触发换相 1.1 整体执行思路 霍尔传感器U、V、W三相通过IO+EXIT中断的方式进行霍尔传感器数据的读取。将IO口配置为上升沿+下降沿中断触发的方式。当霍尔传感器信号发生发生信号的变化就会触发中断在中断

kubelet组件的启动流程源码分析

概述 摘要: 本文将总结kubelet的作用以及原理,在有一定基础认识的前提下,通过阅读kubelet源码,对kubelet组件的启动流程进行分析。 正文 kubelet的作用 这里对kubelet的作用做一个简单总结。 节点管理 节点的注册 节点状态更新 容器管理(pod生命周期管理) 监听apiserver的容器事件 容器的创建、删除(CRI) 容器的网络的创建与删除