Bag of Words - 词袋模型
Bag of Words
模型是信息检索领域常用的文档表示方法。简单来说,不同的单词可以组成一段文本,我们不考虑这些单词出现的频率,那么这段文本就可以用这些单词出现的频率直方图表示。这些单词就是Words
,直方图就是Bag of Words
。
PS:本文提供开箱可用代码进行回环检测实践;代码
举个例子
(来自维基百科Bag of Word):
文本1:John likes to watch movies. Mary likes movies too
文本2:John also likes to watch football games.
词典:["John", "likes", "to", "watch", "movies", "also", "football", "games", "Mary", "too"]
那么这两段文本的Bag of Words
就是:
文本1的Bag of Words
:[1, 2, 1, 1, 2, 0, 0, 0, 1, 1]
文本2的Bag of Words
:[1, 1, 1, 1, 0, 1, 1, 1, 0, 0]
其中的数字表示对应位置单词出现的次数
单词数量过多问题
如果文本中的单词数量过多,那么Bag of Words
的向量就会非常稀疏,而且向量长度会非常大。这样的话,就会导致计算距离的时候,计算量非常大。因此,需要对单词进行聚类,将相似的单词聚类到一起,这样就可以减少单词的数量。
一个最简单的处理方法是使用哈希表(Hashing),将单词通过哈希映射到一个固定的长度的向量中。这样的话,就可以将单词的数量控制在一个固定的长度。但是这样的话,就会导致相似的单词被哈希到不同的位置,这样的话,相似的单词就无法聚类到一起了。
图像的Bag of Words
Bag of Word模型是文本检索中一个很常用的模型。在图像检索中,也可以使用这个模型。但是图像和文本有很大的不同,文本本身可以看作是一个离散信号,由不同的单词组成;但是图像是一个二位矩阵,没法直接当作一些元素的集合。因此,图像的Bag of Words
模型需要经过一些处理才能够使用。
简单来说,通过一些特征提取算法,可以将图像中的特征点提取出来,然后将这些特征点当作单词,就可以使用Bag of Words
模型了。
图像Bag of Words词汇表的构建
通过离线对一大批数据进行特征点提取之后会得到非常多的特征点和特征描述符。通过对这些描述符进行K-mean
/K-means++
聚类,可以得到一批中心点,称为nodes
。在进行聚类的过程中因为求平均,会导致如果特征描述符是二值向量的话,会导致聚类中心点的二值向量不是0或1,而是浮点数。这样的话,就需要对这些中心点的值进行二值化,得到nodes
。这些nodes
就是Bag of Words
中的words
。也就是词汇。
那么Bag of Words
就是图像特征点集在这些词汇的频率直方图(或者说频率向量)
具体来说,新来一个图片,进行特征点提取之后,对于每个特征点,计算其与nodes
中的每个node
的距离,找到最近的node
,然后将这个node
的索引作为这个特征点的word
。这样就得到了这张图片的word
的集合。然后对这个集合进行直方图统计,就得到了这张图片的Bag of Words
。
词汇权重
不同的单词所包含的信息量是不同的,比如frequency
这个词就比the
这个词更有区分度,应该赋予这些高区分度的单词更大的权重,反之亦然。下面介绍几种常用的权重计算方式。
Term-Frequency : TF
Term Frequency(TF) :
直观含义:某个特征在当前图片中出现的频率
Inverse Document Frequency : IDF
Inverse document frequency (IDF):
直观含义:某个特征在数据集中出现的频次
TF-IDF
这是使用比较多的权重计算方式,也是在DBoW2中的默认权重计算方式
Term Frequency – Inverse Document Frequency
直观含义:在整个数据集中频繁出现的词汇,比如说the
(在图像中则是频繁出现的特征)区分度较小,权重应该降低。但是如果某个单词在文档中频繁出现,那么这个单词的权重应该变大
通过tf-idf
的加权,那些较少出现的特征,即更有辨识度的特征就会占据更大的权重。
相关的权重定义在DBoW初始版本的README中有说明
词典及词汇树创建
所谓词典
,就是从大量的特征描述符中聚类得到的nodes
。这些nodes
就是Bag of Words
中的words
。也就是词汇。这些词汇代表了图像中较为有代表性的特征。
Bag of Words
中传统意义上的词典并不具有树结构,只是一些词汇(特征)的集合。但是在DBoW2中,词典是具有树结构的。这样的话,可以通过树结构来加速搜索。树只是用来加速搜索的,树的所有叶子节点的集合才是词典。叶子节点也叫words
.
创建词汇树
最终要构建出如下的词汇树
论文中关于构建词汇树是这样描述的:
To build it, we extract a rich set of features from some training
images, independently of those processed online later. The descriptors
extracted are first discretized into kw binary clusters by performing
k-medians clustering with the k-means++ seeding [22]. The medians
that result in a non binary value are truncated to 0. These clusters
form the first level of nodes in the vocabulary tree. Subsequent levels
are created by repeating this operation with the descriptors associated
to each node, up to Lw times.
创建词典
创建词典的过程实际上只是遍历词汇树,并把所有叶子节点收集起来。
计算单词权重(即叶子节点权重)
这里之以默认的权重计算方式TF-IDF
为例。TF-IDF
的计算方式在上面已经有说明。在创建词典的时候,只能够先计算IDF
项,TF
项是当使用这个词典,对一张图片进行Bag of Words
的时候才能够计算出来。
再次复习,IDF
中的N
是所有的图片的数量,N_i
是包含这个词汇的图片的数量。
因此,计算IDF
的时候也需要利用第一步创建的词汇树,统计每个叶子节点的图片数量。具体的计算过程可以如下:
cpp
for each image:
for each feature in image:
word_id = tree.find(word)
if word_id not been counted:
word_Ni[word_id] += 1
for each word_id:
word_idf[word_id] = log(N / word_Ni[word_id])
词典及词汇树的保存
DBoW2对词典和词汇的保存直接使用的是YAML
结构,同时利用cv:FFileStorage
来进行读写,可以直接保存压缩文件。生成的词典和词汇树文件如下
yaml
%YAML:1.0
---
vocabulary:
k: 9
L: 3
scoringType: 0
weightingType: 0
nodes:
- { nodeId:1, parentId:0, weight:0.,
descriptor:"114 237 18 190 93 135 214 232 143 132 232 11 110 202 37 208 248 251 235 227 78 3 218 255 179 244 143 59 17 63 47 142 " }
- { nodeId:2, parentId:0, weight:0.,
descriptor:"190 116 236 103 236 127 255 216 123 238 247 191 91 125 223 59 255 221 247 231 173 246 255 138 127 251 229 181 231 253 94 243 " }
- ...
- { nodeId:9, parentId:0, weight:0.,
descriptor:"208 188 159 182 168 236 17 7 190 159 130 25 247 183 64 91 161 119 109 16 58 77 147 35 217 227 126 89 128 64 135 43 " }
- { nodeId:717, parentId:9, weight:0.,
descriptor:"208 62 159 183 170 106 52 55 190 191 130 245 247 183 192 91 167 119 108 0 57 205 131 2 200 163 127 144 168 112 135 27 " }
- { nodeId:718, parentId:9, weight:0.,
descriptor:"208 189 31 254 43 204 85 103 185 19 226 56 190 183 32 115 162 247 108 0 105 203 146 111 209 243 86 27 0 16 167 43 " }
- ...
- 省略后续所有节点...
words:
- { wordId:0, nodeId:19 }
- { wordId:1, nodeId:20 }
- { wordId:2, nodeId:21 }
- { wordId:3, nodeId:22 }
- { wordId:4, nodeId:23 }
- 省略后续所有单词...
上面有几点值得注意:
- 由于上文提到的创建词汇树时候的遍历顺序问题(实际上即不是广度优先也不是深度优先遍历),因此会出现根节点的最后一个子节点,也就是上面的
nodeId:9
的子节点的序号特别大,这个序号是根据遍历顺序来的,而不是根据树的结构来的。 - 特征描述符(以ORB特征为说明)在DBoW2中的类型是
cv::Mat, type = CV_8UC1
的矩阵,一个ORB
特征一共256bit,因此保存的时候使用32个unsigned char
来保存。可以看到上面的descriptor
字段是一串32个数字,每个数字在0-255之间 - 只有叶子节点(也就是
word
)才有权重,其他的权重都是0
添加图片到数据库
添加图片到数据库的过程可以理解成在实时运行时,对新增的图片计算Bag of Words
,同时要计算所谓Direct index
和Inverted index
。
Inverted index
存储的是每个word
对应的图片的索引。当一个新的图片被送进来计算Bag of Words
时,就可以得到该图像每个word中包含的其他图片的索引。这样可以大大加速搜索。
Direct index
存储的是每个图片的word
中包含的特征点的索引。主要是用来做特征点匹配加速使用。当一帧图像被送进来,并通过某种方式(上一帧或者回环检测)得到其对应的图片\prime{I_t}
的时候,就可以通过Direct index
找到这两帧图像中相同word
中包含的特征点,在这些特征点之间再进行检索和匹配会比直接对所有特征点进行距离计算、匹配要快很多。
通过特征集合计算Bag of Words
实际上更准确的描述是特征集合
转Bag of Words
。需要先用特征点检测器提取特征点,然后用特征描述器计算特征描述符。然后对这些特征描述符进行Bag of Words
。
每一个特征可以通过词汇树
找到最终的一个node
,这个node
的投票加1。对所有特征执行这个操作这后就得出了words
的直方图。这个直方图就是Bag of Words
。
这里的特征集合是指一张图片中的所有特征点的描述子。
具体的过程其实很简单:
- 对于每个特征点,找到其对应的
node
,也就找到了这个特征点的word
。 - 收集该
word
的权重,并在BoW vector
的对应位置上加上这个权重。 - 遍历完所有的特征点之后,对
BoW vector
进行归一化,归一化后的vector
就是这张图片的Bag of Words
。
实现中的TF-IDF权重的计算方式
IDF
的计算在上面已经有说明,每个word
的IDF
在词典构建的时候就已经确定了。至于TF
项,也就是某个word
在某张图片中出现的频率,并没有被显式的计算出来。而是在计算BoW vector
的时候,直接对每个feature
的word
的权重进行累加。这样的话,后续再进行一次归一化,BoW vector
中的每个word
的权重就是TF-IDF
数据库的存储结构
数据库的存储结构在词典/词汇表的存储结构后面添加了一个database
字段
yaml
%YAML:1.0
---
vocabulary:
k: 9
L: 3
scoringType: 0
weightingType: 0
nodes:...
words:...
database:
nEntries: 4
usingDI: 1
diLevels: 0
invertedIndex:
-
- { imageId:0, weight:1.5644960771493644e-03 }
- { imageId:2, weight:2.0755306357238515e-03 }
-
- { imageId:3, weight:4.0272643792627289e-03 }
-
- { imageId:0, weight:1.5644960771493644e-03 }
- { imageId:2, weight:4.1510612714477030e-03 }
-
- { imageId:0, weight:1.5644960771493644e-03 }
- { imageId:1, weight:3.7071104700705723e-03 }
- ...
directIndex:
- # 图片1
-
nodeId: 19
features:
- [ 20, 40, 256, 300, ..., feature_id]
-
nodeId: 21
features:
- [ 34 ]
-
nodeId: 22
features:
- [ 207 ]
- # 图片2
-
nodeId: 19
features:
- [ 256 ]
- ...
- # 图片3
-
nodeId: 19
features:
- [ 256 ]
- ...
- # 图片4
-
nodeId: 19
features:
- [ 256 ]
- ...
使用数据库检索相似图片及特征点匹配
图像检索
过程可以拆解成两个步骤:
- 计算
Bag of Words
,得到BoW vector
,记为(注意,的长度为词汇表的长度,也就是词汇树叶子节点的个数) - 从数据库中检索与该
BoW vector
距离最近的图片
但是实际上,由于有了Inverse index
,可以直接从Inverse index
拿到中每一个word
对应的图片的索引。这样就可以直接从数据库中拿到这些图片,然后再计算这些图片与的距离,找到最近的图片。实际计算步骤如下:
cpp
image_value: map<ImageId, RelavantCoefficient>
return: map<ImageId, RelavantCoefficient>
for each word in v_t:
for each image in invertedIndex[word]:
coeff = fabs(word.weight - image.weight) - fabs(word.weight) - fabs(image.weight)
image_value[image.imageId] += coeff
sort image_value by value (ascending)
select top N images from image_value -> return
for each image in return:
image.coeff = -image_value[image.imageId] * 0.5
这里唯一比较奇怪的是权重的计算。理论上计算BoW vector
的距离计算是这样的(以L1
距离为例):
这样计算出来的分数在0-2之间,0代表最好,2代表最差。但是在DBoW2为了将最后的分数控制在0-1之间,并且0表示最差,1表示最好,因此对上面的距离计算进行了一些变换:
首先,L1
距离可以写成如下形式:
特征点匹配
在DBoW2中,为了加速特征点匹配的搜索过程,在图像加入数据库的时候,额外保存该图像的每个word
所对应的特征点集合,称做所谓的direct index
。这样的话,当进行两张图像的特征点匹配的时候,只需要对两张图像逐一遍历word
,对两张图片中该word
所包含的特征点进行__Exhaustive__的特征点匹配,就可以找到匹配的特征点。由于每个word
中包含的特征数量都很有限,因此可以大大提升特征点匹配的效率。
这里比较值得学习的是代码中的数据结构的设计。在DBoW2中,主要用来存储一个图片的数据结构有以下几种
特征点匹配的步骤在DBoW2中,主要是用来当通过BoW vector
及一些其他手段检索出__一张__分数最高的候选图片之后,再对这个候选图片进行特征点匹配。通过图片之间的匹配特征点集,可以计算两者的基础矩阵(Fundamental Matrix
)。具体怎么算基础矩阵后文再详细讲解,本小节先重点讲解特征点匹配的过程。
特征点匹配算法
输入
步骤
cpp
for d1,d2 in (D1,D2):
for f1_id in d1.featureIds:
best_dist_1 = DOUBLE_MAX
best_dist_2 = DOUBLE_MAX
best_f2_id = -1
for f2_id in d2.featureIds:
dist = distance(F1[f1_id], F2[f2_id]) // 计算特征之间的距离(注意是二值化特征)
if dist < best_dist_1:
best_dist_2 = best_dist_1
best_dist_1 = dist
best_f2_id = f2_id
else if dist < best_dist_2:
best_dist_2 = dist
if best_dist_1 < best_dist_2 * THRESHOLD: // 最近距离需要比第二近距离小一定的阈值
if f1_id not in M:
M.push_back((f1_id, best_f2_id))
else:
compare the distance of (F1[f1_id], F2[M[f1_id]]) and (F1[f1_id], F2[best_f2_id])
and choose the small one
使用DBoW2做回环检测
回环检测是在DBoW2作者的另外一个仓库DLoopDetector中实现的。DLoopDetector利用DBoW2库做Bag of word
构建,同时利用Direct Index
加速特征点匹配,用匹配后的特征点对求解基础矩阵,从而判断找到图像是否符合物理约束,即求解出来的基础矩阵应该能够使特征点重投影误差很小,这个步骤叫做geometric verification
。
做回环检测的时候本质上就是找到一个相似图片,但是为了保证准确率足够高(需要接近100%,因为回环检测如果错误那么整个轨迹都会乱掉。当然,召回率就可以不需要这么高。简单说来就是可以检测不出来,但是检测出的必须得正确)。为了达到这么高的准确率,在回环检测的时候还用到了一些特殊处理方法用来筛选候选的回环匹配帧:
分数归一化
使用当前帧与上一帧的分数作为分母,归一化候选帧的分数。归一化分数的原因是BoW vector
的距离计算受各种条件影响比较大,使用归一化后的分数可以方便确定一个统一阈值。在实际中,为了计算当前帧与上一帧的匹配分数,是通过缓存上一帧的BoW vector
进行实现的。设定的阈值一般不大,默认是0.3。
候选帧聚合
将检索到的一批候选帧按照帧序号进行“聚合”,论文中叫做“island”。也就是将相近序号的帧聚合在一起,当前后两帧间距小于阈值时认为属于同一个island,当聚合的帧数足够才认为这是一个合格的候选“island”。每个island
的分数为其中所有帧的分数之和。挑选分数最大的island
作为最终的匹配island
。同时,该island
中最大分数的帧为匹配的帧。
时间连续一致性
代码及实操
DBoW2中的回环检测器的代码放置在DLoopDetector。实际运行的时候,需要先解决一下依赖问题,可能还有一些bug需要修复。但是一个没法忍受的点是,DBoW2默认的词典保存格式是yml
,在读取DLoopDetector提供的词典文件的时候,花费了几个小时还没有读取结束。在ORB_SLAM中,作者将词典保存为txt
格式,这样读取起来非常快,只需要花费几秒。
笔者将DBoW2和DLoopDetector打包,并修复依赖问题及一些bug,可以直接使用。代码链接
改进点:
- 从txt中读取字典
- 修复了DLoopDetector中的一些bug
- 将DBoW2,DLib,DLoopDetector整合到一个工程中,避免依赖问题和编译问题
- 整个项目只需要依赖OpenCV
- 在DLoopDetector提供的
demo_brief.cpp
基础上,增加了demo_orb.cpp
,并提供字典可以直接运行检验结果
运行效果:
相关资料
- Bag of Words在图像检索中的基础知识
- DBoW
- DBoW2
- k-means++