详解2D激光雷达运动畸变去除

2D激光雷达运动畸变去除

1、激光雷达运动畸变的说明

如果是扫描频率5Hz的激光雷达,一帧数据的首尾时间差200ms,若机器人以0.5m/s的速度向x方向走扫描前面的墙面,那么200ms后尾部的测量距离和首部的测量距离在x方向上相差10cm,如下图所示:

image-20201117115407237

绿色箭头表示激光雷达旋转方向,黑色箭头表示激光雷达运动方向。显然,我们可以看出每个激光束的距离数据都是在不同位姿下采集而来,如橙色、灰色、蓝色点位姿所示;然而,一般激光雷达驱动封装数据时,却默认一帧激光雷达数据的所有激光束是在同一位姿下、瞬时获得的,也就是所有激光束都是从橙色点获得的数据,这样实际使用的数据和真实数据有明显差距,如10cm。所以,对于低帧率的激光雷达,运动畸变明显是个需要被解决的问题。

2、去除运动畸变的原理

原理:在一帧激光雷达数据中,为每个激光束的原点都找到一个近似的里程计位姿,并且认为该激光束是在该里程计位姿上进行数据采集的,得到这些基础数据之后,进行系列的坐标变换,把所有激光点的数据都变换到第一束激光原点的坐标系下,然后再封装数据,这样就能极大的减小畸变。

3 、如何使用里程计辅助去除运动畸变

3.1、大致流程:

用CPU读取激光雷达数据,同时单片机上传里程计数据,两者进行时间同步、数据对齐;

分段线性近似、线性插值,找到激光束原点近似的里程计位姿和近似的激光雷达坐标系角度;

将所有激光束从近似的激光雷达坐标系变换到里程计坐标系上(激光雷达坐标系—>里程计坐标系);

最后将所有激光束从里程计坐标系变换到基准坐标系上、封装数据(里程计坐标系—>基准坐标系)。

3.2、时间同步、数据对齐

若一帧激光数据的t_s、t_e(开始、结束)时刻在缓存的里程计数据里没有准确对应的位姿,则需要使用 waitForTransform() 函数来等待 tf 的坐标转换线程得到你想要的时间点的坐标转换数据,代码中实现方式如下:

tf_->waitForTransform(“odom”, “laser_link”, dt, ros::Duration(0.5))
ros::Duration(0.5) : 为 waitForTransform() 函数 的结束条件:最多等待 0.5 秒,如果提前得到了坐标的转换信息,直接结束等待。
3.3、分段线性近似、线性插值

这里先假设机器人在一帧激光数据采集过程中是匀加速、匀速、匀减速运动中一种或者多种混合都行。机器人的位姿是关于时间t的分段函数,如下图所示;这里只给出了机器人匀减速运动的解释,其他运动方式都是一样的处理方式。

image-20201117135659365

上图中,p_s和 p_e是一帧激光数据的激光雷达坐标系原点对应的里程计开始和结束位姿,这里先按照分段时间把一帧里程计进行分段处理,得到如①、②区域代表的不同分段。

然后对每个分段进行线性近似,如①区域橙色虚线所示;然后再进行线性插值,如①区域绿色线;如此往复,对一帧数据中的每个分段都进行线性近似、线性插值。这样就我们就获得了每个激光束的原点对应的里程计位姿(激光雷达坐标系在里程计坐标系下的位姿)。

3.4 、坐标转换流程

激光雷达坐标系—>轮式里程计坐标系—>基准坐标系

在上面的插值完成后,在近似位姿的激光雷达坐标系下将每个激光点变换到里程计坐标系下,再将每个激光点的数据从里程计坐标系下变换到基准坐标系下(绿色A坐标系)。

image-20201118162602136

这里是将分段①(一帧数据的第一个分段)进行线性插值和坐标变换的过程放大化了,方便理解,如上图所示。

odom蓝色坐标系:机器人里程计坐标系

绿色坐标系:运动过程中的激光雷达坐标系,若干。

A点绿色坐标系:采集第一束激光数据时刻的激光雷达坐标系,这里也当做基准坐标系。

B点绿色坐标系:该分段中采集某一个激光束BD时刻的激光雷达坐标系。

A、C两点:近似线性分段的起始和结束点,A、C坐标系和坐标是从tf树获得,分别是激光雷达坐标系、以及坐标系的里程计位姿。

AC橙色虚直线:对AC弧线运动过程的线性近似,这里是放大后的效果图。

D点:激光束BD的终点。

B点蓝色坐标系:和里程计坐标方向一致、相互平行的坐标系。

激光雷达驱动实际采集到的数据:如B点绿色坐标系下的激光束BD的数据(距离和角度),该数据存储在/scan中,但存储数据时认为该数据是在A点绿色坐标系下采集的,所以存在运动畸变。

很明显,我们要将D点从B点绿色坐标系变换到A点绿色坐标系,如从黑色BD到绿色AD,此时我们就获得了畸变情况下在基准坐标下的D点的数据。于是就完成了一个激光点的去运动畸变过程,其他同理。

这里实现的C++代码流程是:

(1)把该插值B对应的激光点D的距离和角度,在近似的激光雷达坐标系(如绿色B坐标系)的XY轴计算出分量;

(2)通过旋转矩阵,将绿色B坐标系下的激光点D旋转到蓝色B坐标系,在通过平移将蓝色B坐标系下的激光点D平移到里程计坐标系下,经过计算得出激光点D在里程计坐标系下的位姿;

(3)同样,通过旋转+平移,将里程计坐标系下的D点变换到基准坐标系(绿色A坐标系)下,这样就得到激光点D在绿色A坐标系下的XY轴分量。

(4)根据XY轴分量根据sqrt(dx2+dy2)算出激光点距离,atan2(dy,dx)算出角度,对于所有激光束都是按照上述步骤处理。

4、 坐标变换涉及的数学知识

从B点绿色坐标系将激光点D变换到odom坐标系;

image-20201118222341802

从odom坐标系将激光点D变换到该帧数据的基准坐标系(A点绿色坐标系);

在这里插入图片描述

5、 低帧率激光雷达运动畸变去除程序流程

image-20201117145337863

6、 核心代码

//scan回调函数
void LidarMotionCalibrator::ScanCallBack(const sensor_msgs::LaserScanConstPtr& scan_msg)
{
    ros::Time startTime, endTime;
    //一帧scan的时间戳就代表一帧数据的开始时间
    startTime = scan_msg->header.stamp;
    sensor_msgs::LaserScan laserScanMsg = *scan_msg;
    int beamNum = laserScanMsg.ranges.size();
    //根据激光时间分割和激光束个数的乘机+startTime得到endTime(最后一束激光束的时间)
    endTime = startTime + ros::Duration(laserScanMsg.time_increment * beamNum);

    //拷贝数据到angles,ranges
    std::vector<double> angles,ranges;
    for(int i = 0; i < beamNum;i++)
    {
        double lidar_dist = laserScanMsg.ranges[i];//单位米
        double lidar_angle = laserScanMsg.angle_min + laserScanMsg.angle_increment * i;//单位弧度
        ranges.push_back(lidar_dist);
        angles.push_back(lidar_angle);
    }

#if debug_
    visual_cloud_.clear();
    //数据矫正前、封装激光点云可视化、红色
    visual_cloud_scan(ranges,angles,255,0,0);
#endif

    //激光雷达运动畸变去除函数
    Lidar_Calibration(ranges,angles,startTime,endTime,tf_);

#if debug_
    //数据矫正后、封装打算点云可视化、绿色
    visual_cloud_scan(ranges,angles,0,255,0);
    g_PointCloudView.showCloud(visual_cloud_.makeShared());
#endif
}
//激光雷达运动畸变去除函数
void LidarMotionCalibrator::Lidar_Calibration(std::vector<double>& ranges,std::vector<double>& angles,ros::Time startTime,ros::Time endTime,tf::TransformListener * tf_)
{
    //激光束的数量
    int beamNumber = ranges.size();
    //分段时间间隔,单位us
    int interpolation_time_duration = 5 * 1000;//单位us

    tf::Stamped<tf::Pose> frame_base_pose; //基准坐标系原点位姿
    tf::Stamped<tf::Pose> frame_start_pose;
    tf::Stamped<tf::Pose> frame_mid_pose;

    double start_time = startTime.toSec() * 1000 * 1000;      //*1000*1000转化时间单位为us
    double end_time   = endTime.toSec() * 1000 * 1000;
    double time_inc   = (end_time - start_time) / beamNumber; //每相邻两束激光数据的时间间隔,单位us

    //得到start_time时刻,laser_link在里程计坐标下的位姿,存放到frame_start_pose
    if(!getLaserPose(frame_start_pose, ros::Time(start_time /1000000.0), tf_))
    {
        ROS_WARN("Not Start Pose,Can not Calib");
        return ;
    }
    //分段个数计数
    int cnt = 0;
    //当前插值的段的起始坐标
    int start_index = 0;
    //默认基准坐标系就是第一个位姿的坐标系
    frame_base_pose = frame_start_pose;

    for(int i = 0; i < beamNumber; i++)
    {
        //按照分割时间分段,分割时间大小为interpolation_time_duration
        double mid_time = start_time + time_inc * (i - start_index);
        //这里的mid_time、start_time多次重复利用
        if(mid_time - start_time > interpolation_time_duration || (i == beamNumber - 1))
        {
            cnt++;
            //得到临时结束点的laser_link在里程计坐标系下的位姿,存放到frame_mid_pose
            if(!getLaserPose(frame_mid_pose, ros::Time(mid_time/1000000.0), tf_))
            {
                ROS_ERROR("Mid %d Pose Error",cnt);
                return ;
            }
            //计算该分段需要插值的个数
            int interp_count = i + 1 - start_index ; 
            //对本分段的激光点进行运动畸变的去除
            Lidar_MotionCalibration(frame_base_pose,  //对于一帧激光雷达数据,传入参数基准坐标系是不变的
                                    frame_start_pose, //每一次的传入,都代表新分段的开始位姿,第一个分段,根据时间戳,在tf树上获得,其他分段都为上一段的结束点传递
                                    frame_mid_pose,   //每一次的传入,都代表新分段的结束位姿,根据时间戳,在tf树上获得
                                    ranges,           //引用对象,需要被修改的距离数组
                                    angles,           //引用对象,需要被修改的角度数组
                                    start_index,      //每一次的传入,都代表新分段的开始序号
                                    interp_count);    //每一次的传入,都代表该新分段需要线性插值的个数

            //更新时间
            start_time = mid_time;
            start_index = i;     
            frame_start_pose = frame_mid_pose;        //将上一分段的结束位姿,传递为下一分段的开始位姿
        }
    }
}
//从tf缓存数据中,寻找laser_link对应时间戳的里程计位姿
bool LidarMotionCalibrator::getLaserPose(tf::Stamped<tf::Pose> &odom_pose,ros::Time dt,tf::TransformListener * tf_)
{
    //初始化
    odom_pose.setIdentity();
    //定义一个 tf::Stamped 对象,构造函数的入口参数(const T& input, const ros::Time& timestamp, const std::string & frame_id)
    tf::Stamped <tf::Pose> tmp(odom_pose, dt, scan_frame_name_);
    try
    {
        //阻塞直到可能进行转换或超时,解决时间不同步问题
        if(!tf_->waitForTransform(odom_name_, scan_frame_name_, dt, ros::Duration(0.5)))             // 0.15s 的时间可以修改
        {
            ROS_ERROR("LidarMotion-Can not Wait Transform()");
            return false;
        }
        //转换一个带时间戳的位姿到目标坐标系odom_name_的位姿输出到odom_pose
        tf_->transformPose(odom_name_, tmp, odom_pose);
    }
    catch(const std::exception& e)
    {
        std::cerr << e.what() << '\n';
    }

    return true;
}
//根据传入参数,对任意一个分段进行插值
void LidarMotionCalibrator::Lidar_MotionCalibration(tf::Stamped<tf::Pose> frame_base_pose,tf::Stamped<tf::Pose> frame_start_pose,tf::Stamped<tf::Pose> frame_end_pose,
                                                    std::vector<double>& ranges,std::vector<double>& angles,
                                                    int startIndex,int& beam_number)
{
    //beam_step插值函数所用的步长
    double beam_step = 1.0 / (beam_number-1);
    //该分段中,在里程计坐标系下,laser_link位姿的起始角度 和 结束角度,四元数表示
    tf::Quaternion start_angle_q = frame_start_pose.getRotation();
    tf::Quaternion   end_angle_q = frame_end_pose.getRotation();
    //该分段中,在里程计坐标系下,laser_link位姿的起始角度、该帧激光数据在里程计坐标系下基准坐标系位姿的角度,弧度表示
    double  start_angle_r = tf::getYaw(start_angle_q);
    double   base_angle_r = tf::getYaw(frame_base_pose.getRotation());
    //该分段中,在里程计坐标系下,laser_link位姿的起始位姿、结束位姿,以及该帧激光数据在里程计坐标系下基准坐标系的位姿
    tf::Vector3 start_pos = frame_start_pose.getOrigin(); start_pos.setZ(0);
    tf::Vector3   end_pos = frame_end_pose.getOrigin();   end_pos.setZ(0);   
    tf::Vector3  base_pos = frame_base_pose.getOrigin();  base_pos.setZ(0);
    //临时变量
    double mid_angle;
    tf::Vector3 mid_pos;
    tf::Vector3 mid_point;
    double lidar_angle, lidar_dist;

    //beam_number为该分段中需要插值的激光束的个数
    for(int i = 0; i< beam_number;i++)
    {
        //得到第i个激光束的角度插值,线性插值需要步长、起始和结束数据,与该激光点坐标系和里程计坐标系的夹角
        mid_angle =  tf::getYaw(start_angle_q.slerp(end_angle_q, beam_step * i));  //slerp()角度线性插值函数
        //得到第i个激光束的近似的里程计位姿线性插值
        mid_pos = start_pos.lerp(end_pos, beam_step * i);  //lerp(),位姿线性插值函数
        //如果激光束距离不等于无穷,则需要进行矫正
        if( std::isinf(ranges[startIndex + i]) == false)
        {
            //取出该分段中该束激光距离和角度
            lidar_dist  =  ranges[startIndex+i];
            lidar_angle =  angles[startIndex+i];
            //在当前帧的激光雷达坐标系下,该激光点的坐标 (真实的、实际存在的、但不知道具体数值)
            double laser_x = lidar_dist * cos(lidar_angle);
            double laser_y = lidar_dist * sin(lidar_angle);
            //该分段中的该激光点,变换的里程计坐标系下的坐标,(这里用插值的激光雷达坐标系近似上面哪个真实存在的激光雷达坐标系,因为知道数值,可以进行计算)
            double  odom_x = laser_x * cos(mid_angle) - laser_y * sin(mid_angle) + mid_pos.x();
            double  odom_y = laser_x * sin(mid_angle) + laser_y * cos(mid_angle) + mid_pos.y();
            mid_point.setValue(odom_x, odom_y, 0);
            //在里程计坐标系下,基准坐标系的参数
            double x0,y0,a0,s,c;
            x0 = base_pos.x();
            y0 = base_pos.y();
            a0 = base_angle_r;
            s  = sin(a0);
            c  = cos(a0);
            /*
                * 把odom转换到base为 [  c s -x0*c - y0*s;
                *                    -s c  x0*s - y0*c;
                *                     0 0  1          ]
            */
           //把该激光点都从里程计坐标系下,变换的基准坐标系下
            double tmp_x,tmp_y;
            tmp_x =  mid_point.x()*c  + mid_point.y()*s  - x0*c - y0*s;
            tmp_y = -mid_point.x()*s  + mid_point.y()*c  + x0*s - y0*c;
            mid_point.setValue(tmp_x,tmp_y,0);
            //然后计算该激光点以起始坐标为起点的 dist angle
            double dx,dy;
            dx = (mid_point.x());
            dy = (mid_point.y());
            lidar_dist = sqrt(dx*dx + dy*dy);
            lidar_angle = atan2(dy,dx);
            //激光雷达被矫正
            ranges[startIndex+i] = lidar_dist;
            angles[startIndex+i] = lidar_angle;
        }
        //如果等于无穷,则随便计算一下角度
        else
        {
            double tmp_angle;            
            lidar_angle = angles[startIndex+i];
            //里程计坐标系的角度
            tmp_angle = mid_angle + lidar_angle;
            tmp_angle = tfNormalizeAngle(tmp_angle);
            //如果数据非法 则只需要设置角度就可以了。把角度换算成start_pos坐标系内的角度
            lidar_angle = tfNormalizeAngle(tmp_angle - start_angle_r);
            angles[startIndex+i] = lidar_angle;
        }
    }
}

7、代码获取方式

在公众号:小白学移动机器人,发送:运动畸变去除,即可获得下载链接。

对于里程计辅助的2D激光雷达运动畸变去除方法描述到这里就结束了,更加具体的部分,代码中都写了详细的注释。对于以上内容简单的说就是数据对齐、线性插值、坐标变换。为了能描述清楚,这里也给大家画出了插图,总体说的比较具体和啰嗦,见谅,见谅。

本人水平有限,难免出现理解不到位甚至是理解错误的地方,欢迎指出,欢迎交流,私信小编或者加我微信都可以。

如果你感觉,你真的喜欢我的文章与分享,喜欢解读源码,关注请加我微信,我新建了一个关于移动机器人技术的微信群,我邀你入群。
在这里插入图片描述
以往链接,点击访问。

上一篇我手写了个SLAM算法!
下一篇我手写了个SLAM算法(二)!
系列文章从零搭建ROS机器人

如果你感觉,我的文章比较适合你,关注我,点个赞,给你不一样的惊喜。

稿定设计导出-20200810-205521
在这里插入图片描述

评论 33
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

白茶-清欢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值