VSLAM系列原创08讲 | 如何离线训练BoW字典?终于搞懂了!

代码注释地址:

https://github.com/electech6/ORB_SLAM2_detailed_comments

VSLAM系列原创01讲 | 深入理解ORB关键点提取:原理+代码

VSLAM系列原创01讲 | 深入理解ORB关键点提取:原理+代码

VSLAM系列原创02讲 | ORB描述子如何实现旋转不变性?原理+代码

VSLAM系列原创03讲 | 为什么需要ORB特征点均匀化?

VSLAM系列原创04讲 | 四叉树实现ORB特征点均匀化分布:原理+代码

VSLAM系列原创05讲 | 单目初始化中如何进行特征匹配?原理+代码

VSLAM系列原创06讲 | 地图点投影进行特征匹配

VSLAM系列原创07讲 | 词袋有什么用?ORB特征点构建BoW是否靠谱?

接上回继续。。。


离线训练字典

师兄:离线训练字典的流程是这样的:

  • 第1步:准备好足够数量的图像数据集。这个数据集最好涵盖不同光照、不同场景、不同天气、不同季节等条件下拍摄的图像集合,尽量种类多而不重复,比如ORB-SLAM2中使用的字典训练数据集包括几万张图片。这样做是为了尽可能多的涵盖不同的情况,使得ORB-SLAM2在各种情况下词袋都能工作。当然如果你只在某个特定场景下使用的话,可以只采集该场景下尽可能不同类型的图像。

  • 第2步:遍历以上所有的训练图像,对每幅图像提取ORB特征点。最后得到特征点的总数目是非常大的,比如ORB-SLAM2使用的离线字典就有超过108万个特征点。

  • 第3步:下面开始建立字典树。为了方便理解,我以现实生活中的一个例子来说明。这个字典树的生成过程类似一个国家自上而下各级机构的建立过程。我们假设一个国家从上到下的结构是:中央、省、市、镇、村。首先设定字典树的分支数和深度。这里的分支数可以类比为平行机构的数目,类比为每个省有个市,每个市有个镇,每个镇有个村。这里的深度L就是这个各级机构的层数,在这个例子里就是5层。

    将提取到的所有图像特征点的描述子用K-means聚类,变成K个集合,作为字典树的第1层级。这类似于把所有公民按照某种相似的属性分成K个省。然后对每个集合内部重复聚类操作,就得到了字典树的第2层级,这类似于把每个省的公民按照某种相似的属性分成个市。然后再对第2,3,,层级每个集合内部重复上述聚类操作,最后得到深度为,分支数为的字典树。如下图所示,第0层就是根节点,离根节点最远的一层就是叶子,也称为单词(Word)。

  • 第4步:根据每个单词在训练集中出现的频率给每个单词赋予一定的权重,训练集里出现的次数越多,说明辨别力越差,赋予的权重就越低。

师兄:通常我们是通过第三方库DBoW2,或更新的版本DBoW3来训练数据集生成字典。利用DBoW2库里的函数,我们可以很方便的把训练好的字典保存为txt文件,这个字典文件是通用的,我们也可以拿别人训练好的字典来用。具体代码见:

/** * @brief 将训练好的字典保存为txt格式的文件 * @param filename   要保存的文件名称 */template<class TDescriptor, class F>void TemplatedVocabulary<TDescriptor,F>::saveToTextFile(const std::string &filename) const{    // 打开文件    fstream f;    f.open(filename.c_str(),ios_base::out);    // 第一行打印字典树的分支数、深度、评分方式、权重计算方式    f << m_k << ' ' << m_L << ' ' << ' ' << m_scoring << ' ' << m_weighting << endl; // 开始遍历每个节点信息并保存    for(size_t i=1; i<m_nodes.size();i++)    {        // 取出某个节点        const Node& node = m_nodes[i];        // 每行第1个数字为父节点id        f << node.parent << ' ';        // 每行第2个数字标记是(1)否(0)为叶子(单词)        if(node.isLeaf())            f << 1 << ' ';        else            f << 0 << ' ';        // 接下来存储256位描述子,最后存储节点权重        f << F::toString(node.descriptor) << ' ' << (double)node.weight << endl;    } // 关闭文件    f.close();}

(左右滑动看完整代码)

我们打开ORB-SLAM2加载的字典ORBvoc.txt来核对一下,打开字典文件是这样的:

10 6  0 0  
0 0 252 188 188 242 169 109 85 143 187 191 164 25 222 255 72 27 129 215 237 16 58 111 219 51 219 211 85 127 192 112 134 34  0
0 0 93 125 221 103 180 14 111 184 112 234 255 76 215 115 153 115 22 196 124 110 233 240 249 46 237 239 101 20 104 243 66 33  0
...

和我们保存时的定义一样,第一行的10,6,0,0分别是字典树的分支数、深度、评分方式、权重计算方式。

第二行的第1个0表示父节点ID是0,第2个0表示该节点不是叶子(单词),后面的252,188,188,242,...表示存储的256位的描述子。

小白:确实是和我们保存时候对应的,那如何加载我们训练好的或者别人训练好的字典呢?

师兄:DBoW2库里也有相关的函数,在看源码之前我们先来计算一下给定参数后,所有节点的数目。

假设 表示树的分支数, 表示树的深度,这里的深度不考虑根节点 ,是从根节点下面开始算总共有层深度,最后叶子层总共有 个叶子(单词)。那么所有的节点数目是一个等比数列求和问题:

等比数列前项和通项公式为:

式中,分别是等比数列的首项、末项和公比,为前n项和。

套用上面公式,最后所有的节点数目应该是:

我们一起来看一下源码:

/** * @brief  加载训练好的txt格式字典 * @param  filename              字典文件名称 * @return true                    加载成功 * @return false                   加载失败 */template<class TDescriptor, class F>bool TemplatedVocabulary<TDescriptor,F>::loadFromTextFile(const std::string &filename){    // 打开文件    ifstream f;    f.open(filename.c_str()); // 如果为空,返回false    if(f.eof()) return false; // 清空变量    m_words.clear();    m_nodes.clear(); // 读取第一行内容    string s;    getline(f,s);    stringstream ss;    ss << s;    ss >> m_k;     // 树的分支数目    ss >> m_L;     // 树的深度    int n1, n2;    ss >> n1;  // 评分方式    ss >> n2;  // 权重计算方式 // 如果不满足参数要求,认为加载错误,返回false    if(m_k<0 || m_k>20 || m_L<1 || m_L>10 || n1<0 || n1>5 || n2<0 || n2>3)    {        std::cerr << 'Vocabulary loading failure: This is not a correct text file!' << endl;       return false;    }

    m_scoring = (ScoringType)n1;      // 评分类型    m_weighting = (WeightingType)n2;  // 权重类型    createScoringObject();

    // 总共节点数,是一个等比数列求和    // !bug 没有包含最后叶子节点数,应改为((pow((double)m_k, (double)m_L + 2) - 1)/(m_k - 1))    // !不过没有影响,因为这里只是reserve,实际存储是一步步resize实现    int expected_nodes = (int)((pow((double)m_k, (double)m_L + 1) - 1)/(m_k - 1));    m_nodes.reserve(expected_nodes);    // 预分配空间给单词(叶子)向量    m_words.reserve(pow((double)m_k, (double)m_L + 1));

    // 第一个节点是根节点,id设为0    m_nodes.resize(1);    m_nodes[0].id = 0;    // 开始遍历所有的节点直到文件末尾    while(!f.eof())    {        string snode;        getline(f,snode);        stringstream ssnode;        ssnode << snode;  

        // nid表示当前节点id,实际的读取顺序,从0开始        int nid = m_nodes.size();        // 节点容量加1        m_nodes.resize(m_nodes.size()+1);     m_nodes[nid].id = nid;

        // 读每行的第1个数字,表示父节点id        int pid ;        ssnode >> pid;        // 记录节点id的相互父子关系        m_nodes[nid].parent = pid;        m_nodes[pid].children.push_back(nid);

        // 读取第2个数字,表示是否是叶子(单词)        int nIsLeaf;        ssnode >> nIsLeaf;

        // 每个特征点描述子是256 bit,一个字节对应8 bit,所以一个特征点需要32个字节存储。        // 这里 F::L=32,也就是读取32个字节,最后以字符串形式存储在ssd        stringstream ssd;        for(int iD=0;iD<F::L;iD++)        {            string sElement;            ssnode >> sElement;            ssd << sElement << ' ';       }        // 将ssd存储在该节点的描述子        F::fromString(m_nodes[nid].descriptor, ssd.str());

        // 读取最后一个数字:节点的权重(单词才有)        ssnode >> m_nodes[nid].weight;

        if(nIsLeaf>0)        {               // 如果是叶子(单词),存储到m_words             int wid = m_words.size();            m_words.resize(wid+1);

            // 存储单词的id,具有唯一性            m_nodes[nid].word_id = wid;              // 构建 vector<Node*> m_words,存储单词所在节点的指针            m_words[wid] = &m_nodes[nid];        }        else        {            // 非叶子节点,直接分配 m_k个分支            m_nodes[nid].children.reserve(m_k);        }    } // 返回读取成功    return true;

}

(左右滑动看完整代码)

另外,关于权重类型和评分类型代码里是这样定义的:

/// Weighting type
enum WeightingType
{
  TF_IDF,       //0
  TF,           //1
  IDF,          //2
  BINARY        //3
};

/// Scoring type
enum ScoringType
{
  L1_NORM,      //0
  L2_NORM,      //1
  CHI_SQUARE,   //2
  KL,           //3
  BHATTACHARYYA,//4    
  DOT_PRODUCT,  //5
};

(左右滑动看完整代码)

(0)

相关推荐