Advertisement

自动驾驶系统进阶与项目实战(四)自动驾驶高精度地图构建中的三维场景识别和闭环检测

阅读量:

自动驾驶高精度地图构建中,闭环检测通过三维点云的Scan Context描述符实现场景识别和误差纠正。该方法利用旋转不变性和压缩特性提取二维特征图,并通过KD树快速搜索匹配历史场景。当两个场景的距离小于阈值时认为存在闭环,从而优化地图闭合度。

自动驾驶系统进阶与项目实战(四):深入学习与实践高精度地图构建中的复杂环境三维重建及闭环系统的验证与优化

在SLAM体系中,闭环检测被视为一项不可或缺的关键技术,并同时构成了自动驾驶领域高精度地图构建的核心支撑。其本质是让无人车识别SLAM构图阶段生成的闭合路径,并通过这一机制精炼里程计数据的累积误差。最终效果表现为构建出的SLAM地图能够在闭合路径的连接点实现精准吻合,在同一路段经过多次测量后(主要以激光点云和图像数据为主),都能确保数据的一致性和完整性。

在这里插入图片描述

在闭环检测体系中,场景识别环节扮演着至关重要的角色。其中的场景辨识过程即是通过无人车对当前环境与历史数据中的相似区域进行精确匹配完成的任务。当发现当前观测所得的环境特征与历史记录中的某一特定区域相吻合时,则系统能够推断出该区域之前已经被访问过,并在此基础上实现对该区域位置信息的持续优化。其中最常见的辨识手段便是基于图像数据的分析技术。然而这种基于视觉信息的方法往往存在敏感性问题:它会受到环境光照条件的变化以及运动目标的影响。

基于点云的数据模型的三维场景识别技术对光照、季节等环境因素的变化较为不敏感;其与数字图像相比,在点云数据中密度较低的特点更为明显,在距离数据上数值越大分布越稀疏而导致噪声干扰也越大;与数字图像相比,在点云数据中密度较低的特点更为明显,在距离数据上数值越大分布越稀疏而导致噪声干扰也越大;与数字图像相比,在点云数据中密度较低的特点更为明显,在距离数据上数值越大分布越稀疏而导致噪声干扰也越大;与数字图像相比,在点云数据中密度较低的特点更为明显,在距离数据上数值越大分布越稀疏而导致噪声干扰也越大;与数字图像相比,在点云数据中密度较低的特点更为明显,在距离数据上数值越大分布越稀疏而导致噪声干扰也越大;

Scan Context描述符构建

构建Scan Context描述符的第一步是通过环(ring)和扇形(sector)方法对点云进行重新排列,并将其转化为二维特征图的形式。该二维特征图如上文所述

在这里插入图片描述

在全局视角下采用层环法对点云进行初步分割(如上图所示的黄色层环),随后通过细扇区分割更小的部分,在此过程中层环与扇区便能够唯一标识点云中的任意区域(如图中所示的黑色区域)。通过二维特征图表征这种关联关系并建立行映射:将层环映射到扇区,则可生成一个相对简单的二维特征表征。
这个方法成功地保留了点云的空间结构特性的同时提取出一个简洁的环境描述符被称为Scan Context。

其中N_sN_r分别代表扇区数量与环的数量,在本文研究中设定点云数据的最大截取范围为L_{max}。根据此设定可得相邻两个环之间的间距即为空间范围内分段间距\frac{L_{max}}{N_r};同时每个扇区对应的中心角大小则由公式\frac{2\pi}{N_s}确定。在实际应用中本研究将扇区数量设定为20个、环的数量设定为60个。通过上述参数配置后可用一个维度大小为(N_r \times N_s)的二维矩阵来表征该区域内的空间分布特征。然而为了提高场景识别算法的有效性并结合相关理论基础论文中采用根位移技术扩展了scan context的空间范围。值得注意的是在开源代码中并未实现这一技术细节

Scan Context之间的距离计算

从点云中提取了Scan Context描述符后,接下来需要定义两个Scan Context之间的距离计算公式来比较它们之间的相似性。具体而言,在给定两个点云的 scan context 分别表示为 I^qI^c 的情况下:

  1. 首先,在每个 scan context 中提取对应的特征向量序列
  2. 计算这两个特征向量序列对应位置上的余弦相似度
  3. 将每对特征向量间的余弦相似度求平均值
  4. 最终得到的距离值即为这两个 scan contexts 间的度量
  5. 为了使该度量具有可比性并适应不同规模的数据集,
  6. 将上述结果除以归一化因子 N_s
  7. 这样就得到了最终的距离计算结果

d(I^q, I^c) = \frac{1}{N_s}\sum_{j=1}^{N_s}(1-\frac{c_j^q c_j^c}{||c_j^q||||c_j^c||})

其中 c_j^qc_j^c 分别是两个scan context的第 j 列的列向量。

余弦距离:该指标也被视为余弦相似度,在信息科学领域被广泛应用以衡量数据间的相似程度。具体而言,在给定两个特征向量A和B的情况下,它们之间的余弦相似性θ由点积与向量长度共同决定:

\theta = \frac{A \cdot B}{||A|| \times ||B||}

如上文所述:

在这里插入图片描述

在本段中,在讨论向量分量时提到:A_{i}B_{i} 分别代表向量 AB 的各个分量。所给定的相似度范围是从-1到1:当两个向量的方向完全相反时(即取值为-1),它们之间的关系最为对立;而当取值为1时,则表明这两个向量具有完全一致的方向指向;在数值为0的情况下,则通常意味着这两个向量处于正交关系中;而对于介于上述情况之间的数值,则反映了部分相似性和差异性的共存。

然而,在某些情况下仅凭列向量的距离计算可能无法满足需求(例如,在某个位置被 revisit时且方向与上一次相反的情况下),在这种情况下scan context矩阵将呈现列特征的平移特性)。如图所示:

在这里插入图片描述

在kitti数据集中同一个地点(尽管不同时间访问)的扫描上下文特征图中,在线采样过程中虽然存在相同的地理位置但由于行偏移现象的影响单独依据列索引计算扫描上下文距离并非合适的方法为了消除由于行偏移导致的影响我们需要对多组扫描上下文进行比对以找到最优配准方案具体而言应该计算所有可能配对间的相似性度量并从中选取最优配准组合这样行偏移的最佳匹配关系便得以明确

n^* = \argmin_{n \in [N_s]}d(I^q, I^c)

最小距离就可以定义为:

D(I^q, I^c) = \min_{n \in [N_s]}d(I^q, I^c)

搜索算法

建立特征描述符(scan context)和相似性(distance)的计算公式之后,在这些基于向量空间的方法中进行分类任务时会遇到一些局限性。具体来说,在图像分类问题中使用该方法可能会导致分类结果不够精确或者出现偏差。为了提高分类效率和准确性,在实际应用中需要特别注意算法的时间复杂度问题,并且要尽可能减少模型参数的数量以降低计算开销。

  • 相似性评分;
  • 最邻近搜索(Nearest Neighbor Search, NNS);
  • 稀疏优化(Sparse Optimization)。
    为了更快地进行搜索,scan context作者将第1和2步合为一步,首先引入ring key的概念,ring key是一个对于旋转不敏感的描述符,从scan context中的行向量中可以通过函数\psi求得该描述符,给定一个ring向量 r_i,使用L_0范数计算\psi(r_i)

\psi(r_i) = \frac{||r_i||_0}{N_s}

在scan context中的每一行向量上计算该函数以生成对应的值从而生成一个完整的ring key这表明该ring key实际上是一个具有N_r个元素的一维向量

k = (\psi(r_1), ... ,\psi(r_{N_r}))

由于Ring具有与车辆姿态无关的独特特性,在传感器旋转方面表现出显著的优势。因此可以定义ring key作为一种在传感器旋转下具有抗变性的特征描述符。然而相对于完整扫描上下文而言它所包含的信息量相对较少但通过应用于特定算法可以在提高搜索效率的同时实现快速定位功能其中向量k被选作构建KD树的关键节点以便识别与当前场景最为相似的一组历史环状关键点通过应用前述距离度量方法评估各候选扫描语境与其对应的历史环状关键点之间的相似度并通过设定合理的阈值标准最终确定最优匹配的历史扫描上下文这些符合闭环条件的历史数据将被视为重访问情境

c^* = \argmin_{c_k in C}D(I^q, I^{c_k}), s.t \ \ D < \tau

在KD树中搜索所得的候选场景集合中提取出一个scan context矩阵C;其中\tau被设定为衡量两个情景之间相似程度的标准参数;经过检测后得到与当前情景高度一致的历史记录项,在三维建模过程中形成了闭合路径。

最终整个场景识别的流程图如下:

在这里插入图片描述

在点云数据中提取scan context描述符后,在scan context中提取ring key特征。通过该特征应用KD树缩减方法确定最邻近的候选帧,在候选帧集合中应用预设的距离计算公式及阈值筛选出具有最大相似度的场景集合,并在此基础上完成闭环检测流程。

参数设定

scan context的参数列表如下:

复制代码
    const double LIDAR_HEIGHT = 2.0; // lidar height : add this for simply directly using lidar scan in the lidar local coord (not robot base coord) / if you use robot-coord-transformed lidar scans, just set this as 0.
    
    const int    PC_NUM_RING = 20; // 20 in the original paper (IROS 18)
    const int    PC_NUM_SECTOR = 60; // 60 in the original paper (IROS 18)
    const double PC_MAX_RADIUS = 80.0; // 80 meter max in the original paper (IROS 18)
    const double PC_UNIT_SECTORANGLE = 360.0 / double(PC_NUM_SECTOR);
    const double PC_UNIT_RINGGAP = PC_MAX_RADIUS / double(PC_NUM_RING);
    
    // tree
    const int    NUM_EXCLUDE_RECENT = 50; // simply just keyframe gap, but node position distance-based exclusion is ok. 
    const int    NUM_CANDIDATES_FROM_TREE = 10; // 10 is enough. (refer the IROS 18 paper)
    
    // loop thres
    const double SEARCH_RATIO = 0.1; // for fast comparison, no Brute-force, but search 10 % is okay. // but may well work for same-direction-revisits, not for reverse-revisits
    // const double SC_DIST_THRES = 0.13; // empirically 0.1-0.2 is fine (rare false-alarms) for 20x60 polar context (but for 0.15 <, DCS or ICP fit score check (e.g., in LeGO-LOAM) should be required for robustness)
    const double SC_DIST_THRES = 0.13; // 0.4-0.6 is good choice for using with robust kernel (e.g., Cauchy, DCS) + icp fitness threshold
    
    // config 
    const int    TREE_MAKING_PERIOD_ = 50; // i.e., remaking tree frequency, to avoid non-mandatory every remaking, to save time cost
    
    
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
    
    AI助手

在论文中建议采用以下配置参数:环的数量(PC_NUM_RING)设置为20个、扇区数量(PC_NUM_SECTOR)设定为60个,并且仅限于距离lidar 80米以内的点;这些参数的取值可根据具体应用的lidar系统进行调整。在KD树配置中,只有当缓存中的帧数超过NUM_EXCLUDE_RECENT时才会触发闭环检测机制;通过设定NUM_CANDIDATES_FROM_TREE来指定KD树中搜索K邻近的候选帧数量,在现有研究中发现10个候选帧足以保证准确性;最终场景相似性阈值SC_DIST_THRES被设定为判断两个场景是否属于同一场景的关键指标,在相似度得分低于该阈值时认为这两个场景具有相同的特征;为了避免频繁重建KD树而浪费计算资源,在实际应用中可将TREE_MAKING_PERIOD_设定为一个合理的周期间隔来优化资源利用率。

C++代码实例

论文作者依次发布了基于 Scan Context 闭环检测技术的 Matlab、Python 和 C++ 实现版本。从实用性角度来看,我们重点考察了 C++ 实现的具体功能模块。具体实现则包含在仓库中的 cpp 目录下的两个关键文件: .cpp 文件中的 SCManager 类以及 .h 文件中的对应接口定义。代码架构简洁直观,在此基础之上首先进行了点云特征的数据提取,随后完成后续算法逻辑的支持功能。

复制代码
    void SCManager::makeAndSaveScancontextAndKeys( pcl::PointCloud<SCPointType> & _scan_down )
    {
    Eigen::MatrixXd sc = makeScancontext(_scan_down); // v1 
    Eigen::MatrixXd ringkey = makeRingkeyFromScancontext( sc );
    Eigen::MatrixXd sectorkey = makeSectorkeyFromScancontext( sc );
    std::vector<float> polarcontext_invkey_vec = eig2stdvec( ringkey );
    
    polarcontexts_.push_back( sc ); 
    polarcontext_invkeys_.push_back( ringkey );
    polarcontext_vkeys_.push_back( sectorkey );
    polarcontext_invkeys_mat_.push_back( polarcontext_invkey_vec );
    
    // cout <<polarcontext_vkeys_.size() << endl;
    
    } // SCManager::makeAndSaveScancontextAndKeys
    
    
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
    
    AI助手

从该流程中可以看出,首先是从点云中提取出ScanContext特征描述符:

复制代码
    MatrixXd SCManager::makeScancontext( pcl::PointCloud<SCPointType> & _scan_down )
    {
    TicToc t_making_desc;
    
    int num_pts_scan_down = _scan_down.points.size();
    
    // main
    const int NO_POINT = -1000;
    MatrixXd desc = NO_POINT * MatrixXd::Ones(PC_NUM_RING, PC_NUM_SECTOR);
    
    SCPointType pt;
    float azim_angle, azim_range; // wihtin 2d plane
    int ring_idx, sctor_idx;
    for (int pt_idx = 0; pt_idx < num_pts_scan_down; pt_idx++)
    {
        pt.x = _scan_down.points[pt_idx].x; 
        pt.y = _scan_down.points[pt_idx].y;
        pt.z = _scan_down.points[pt_idx].z + LIDAR_HEIGHT; // naive adding is ok (all points should be > 0).
    
        // xyz to ring, sector
        azim_range = sqrt(pt.x * pt.x + pt.y * pt.y);
        azim_angle = xy2theta(pt.x, pt.y);
    
        // if range is out of roi, pass
        if( azim_range > PC_MAX_RADIUS )
            continue;
    
        ring_idx = std::max( std::min( PC_NUM_RING, int(ceil( (azim_range / PC_MAX_RADIUS) * PC_NUM_RING )) ), 1 );
        sctor_idx = std::max( std::min( PC_NUM_SECTOR, int(ceil( (azim_angle / 360.0) * PC_NUM_SECTOR )) ), 1 );
    
        // taking maximum z 
        if ( desc(ring_idx-1, sctor_idx-1) < pt.z ) // -1 means cpp starts from 0
            desc(ring_idx-1, sctor_idx-1) = pt.z; // update for taking maximum value at that bin
    }
    
    // reset no points to zero (for cosine dist later)
    for ( int row_idx = 0; row_idx < desc.rows(); row_idx++ )
        for ( int col_idx = 0; col_idx < desc.cols(); col_idx++ )
            if( desc(row_idx, col_idx) == NO_POINT )
                desc(row_idx, col_idx) = 0;
    
    t_making_desc.toc("PolarContext making");
    
    return desc;
    } // SCManager::makeScancontext
    
    
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
    
    AI助手

这里的scan context被定义为一个二维矩阵,并通过采用Eigen库中的Eigen::MatrixXd数据类型进行定义。值得注意的是,在论文中提到的root shift这一概念在此处有所区别:这表明作者并未在开源代码库中实现相应的root shift处理逻辑。通过计算当前点云对应的scan context之后,则可以进一步求取其ring key特征:

复制代码
    MatrixXd SCManager::makeRingkeyFromScancontext( Eigen::MatrixXd &_desc )
    {
    /* * summary: rowwise mean vector
    */
    Eigen::MatrixXd invariant_key(_desc.rows(), 1);
    for ( int row_idx = 0; row_idx < _desc.rows(); row_idx++ )
    {
        Eigen::MatrixXd curr_row = _desc.row(row_idx);
        invariant_key(row_idx, 0) = curr_row.mean();
    }
    
    return invariant_key;
    } // SCManager::makeRingkeyFromScancontext
    
    
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
    
    AI助手

在生成了ring key向量后,在原有框架的基础上对scan context进行sector key提取(相较于现有研究)

复制代码
    MatrixXd SCManager::makeSectorkeyFromScancontext( Eigen::MatrixXd &_desc )
    {
    /* * summary: columnwise mean vector
    */
    Eigen::MatrixXd variant_key(1, _desc.cols());
    for ( int col_idx = 0; col_idx < _desc.cols(); col_idx++ )
    {
        Eigen::MatrixXd curr_col = _desc.col(col_idx);
        variant_key(0, col_idx) = curr_col.mean();
    }
    
    return variant_key;
    } // SCManager::makeSectorkeyFromScancontext
    
    
      
      
      
      
      
      
      
      
      
      
      
      
      
      
    
    AI助手

对每一个用于生成图件的数据块中的点云信息进行特征提取,并将这些特征参数加入队列中;随后执行闭环检测流程。

复制代码
    std::pair<int, float> SCManager::detectLoopClosureID ( void )
    {
    int loop_id { -1 }; // init with -1, -1 means no loop (== LeGO-LOAM's variable "closestHistoryFrameID")
    
    auto curr_key = polarcontext_invkeys_mat_.back(); // current observation (query)
    auto curr_desc = polarcontexts_.back(); // current observation (query)
    
    /* * step 1: candidates from ringkey tree_
     */
    if( polarcontext_invkeys_mat_.size() < NUM_EXCLUDE_RECENT + 1)
    {
        std::pair<int, float> result {loop_id, 0.0};
        return result; // Early return 
    }
    
    // tree_ reconstruction (not mandatory to make everytime)
    if( tree_making_period_conter % TREE_MAKING_PERIOD_ == 0) // to save computation cost
    {
        TicToc t_tree_construction;
    
        polarcontext_invkeys_to_search_.clear();
        polarcontext_invkeys_to_search_.assign( polarcontext_invkeys_mat_.begin(), polarcontext_invkeys_mat_.end() - NUM_EXCLUDE_RECENT ) ;
    
        polarcontext_tree_.reset(); 
        polarcontext_tree_ = std::make_unique<InvKeyTree>(PC_NUM_RING /* dim */, polarcontext_invkeys_to_search_, 10 /* max leaf */ );
        // tree_ptr_->index->buildIndex(); // inernally called in the constructor of InvKeyTree (for detail, refer the nanoflann and KDtreeVectorOfVectorsAdaptor)
        t_tree_construction.toc("Tree construction");
    }
    tree_making_period_conter = tree_making_period_conter + 1;
        
    double min_dist = 10000000; // init with somthing large
    int nn_align = 0;
    int nn_idx = 0;
    
    // knn search
    std::vector<size_t> candidate_indexes( NUM_CANDIDATES_FROM_TREE ); 
    std::vector<float> out_dists_sqr( NUM_CANDIDATES_FROM_TREE );
    
    TicToc t_tree_search;
    nanoflann::KNNResultSet<float> knnsearch_result( NUM_CANDIDATES_FROM_TREE );
    knnsearch_result.init( &candidate_indexes[0], &out_dists_sqr[0] );
    polarcontext_tree_->index->findNeighbors( knnsearch_result, &curr_key[0] /* query */, nanoflann::SearchParams(10) ); 
    t_tree_search.toc("Tree search");
    
    /* *  step 2: pairwise distance (find optimal columnwise best-fit using cosine distance)
     */
    TicToc t_calc_dist;   
    for ( int candidate_iter_idx = 0; candidate_iter_idx < NUM_CANDIDATES_FROM_TREE; candidate_iter_idx++ )
    {
        MatrixXd polarcontext_candidate = polarcontexts_[ candidate_indexes[candidate_iter_idx] ];
        std::pair<double, int> sc_dist_result = distanceBtnScanContext( curr_desc, polarcontext_candidate ); 
        
        double candidate_dist = sc_dist_result.first;
        int candidate_align = sc_dist_result.second;
    
        if( candidate_dist < min_dist )
        {
            min_dist = candidate_dist;
            nn_align = candidate_align;
    
            nn_idx = candidate_indexes[candidate_iter_idx];
        }
    }
    t_calc_dist.toc("Distance calc");
    
    /* * loop threshold check
     */
    if( min_dist < SC_DIST_THRES )
    {
        loop_id = nn_idx; 
    
        // std::cout.precision(3); 
        cout << "[Loop found] Nearest distance: " << min_dist << " btn " << polarcontexts_.size()-1 << " and " << nn_idx << "." << endl;
        cout << "[Loop found] yaw diff: " << nn_align * PC_UNIT_SECTORANGLE << " deg." << endl;
    }
    else
    {
        std::cout.precision(3); 
        cout << "[Not loop] Nearest distance: " << min_dist << " btn " << polarcontexts_.size()-1 << " and " << nn_idx << "." << endl;
        cout << "[Not loop] yaw diff: " << nn_align * PC_UNIT_SECTORANGLE << " deg." << endl;
    }
    
    // To do: return also nn_align (i.e., yaw diff)
    float yaw_diff_rad = deg2rad(nn_align * PC_UNIT_SECTORANGLE);
    std::pair<int, float> result {loop_id, yaw_diff_rad};
    
    return result;
    
    } // SCManager::detectLoopClosureID
    
    
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
    
    AI助手

闭环检测流程如下:首先,在获取当前帧的scan context和ring key的基础上,满足以下两个条件(间隔条件和缓存数量)后方能构建新的KD树。在构建过程中,在队列中的第0帧至size-NUM_EXCLUDE_RECENT帧范围内的ring key被用于生成KD树结构。通过K近邻(KNN)方法,在KD树中进行搜索以获得NUM_CANDIDATES_FROM_TREE个候选帧的具体索引位置。当最终筛选出10个候选帧后,在基于所述距离公式计算每个候选帧与当前帧之间的差异值,并据此得出其相似性得分。当计算所得得分为低于预先设定的阈值SC_DIST_THRES时,则判定该历史场景与当前场景为同一场景,并完成场景识别任务的同时返回所有匹配的历史场景信息。

后记

论文作者于2020年初发布了基于Scan Context与Lego-LOAM算法的完整激光雷达SLAM地图构建方案(ROS平台)。由于Lego-LOAM是一种广泛应用的SLAM技术,并且其方法体系较为复杂全面,在本系列文章后续部分中我将对这一技术进行深入探讨,并结合Scan Context方法设计一个包含LiDAR odometry、闭环检测及三维图优化的地图构建流程。如果本文对您在学习或工作中有所启发,请别忘了点赞以支持!

参考

可参考这篇论文:https://irap.kaist.ac.kr/publications/gkim-2018-iros.pdf

全部评论 (0)

还没有任何评论哟~