博文目录
论文英文全名《Rich feature hierarchies for accurate object detection and semantic segmentation》,中文可以理解为 高精度的目标检测及语义分割的多特征层级。仅通过论文题目可知,R-CNN可以用来处理 目标检测以及语义分割任务。事实上在R-CNN提出之前,各种视觉识别任务的发展主要基于相对传统的机器学习方法(SIFT和HOG特征描述子)。这篇论文说明了与基于简单的HOG特征的系统相比,CNN可以在PASCAL VOC上显著提高目标检测性能。
R-CNN概要思路
- 输入原始图像,使用选择性搜索算法(selective search)在图像上提取2000个候选区(region proposals),采用一定的策略将每一个候选区调整到固定大小以适应CNN的输入;
- 利用CNN提取每一个候选区的特征(固定维度的特征向量);
- 利用提前为每一类别训练好的线性SVM实现分类
R-CNN流程概述
预训练大型分类网络
考虑到标注数据贫乏的数量,作者选择了预训练模型的方式。作者在大型辅助数据集(ILSVRC2012分类)上使用Caffe CNN开源库预先训练CNN,该过程只是训练了CNN的图像分类能力,并不具备目标定位能力(训练标签中并不含有图像中物体的位置信息)。预训练CNN基于ImageNet训练的网络,具有1000路输出。
基于特定数据集微调网络
通过selective search在每一张图片上进行候选区提取,得到候选区后,将候选区变换到匹配CNN输入的维度。重新定义CNN,输出层维度为N+1(N是目标的类别数,1代表背景,作者使用的VOC 2010数据集,N=20,背景为1)。然后加载预训练分类模型,通过设置参数舍弃输出层,替代为重定义的输出层。使用随机梯度下降法(SGD),学习率使用0.001(预训练模型初始学习率的1/10),它允许在不破坏初始化的情况下进行微调。在每一次的迭代训练中采样32个正样本(目标物体类别)及96个负样本(背景)包装成一个128的batch进行训练。这里作者采用了一个trick,将抽样倾向于正样本,因为与背景相比,正样本是非常罕见的。训练完成后,撤去最后的softmax层,最后一层全连接层输出的就是在候选区提取的固定长度的特征向量(论文中使用的是AlexNet作为主干网络,提取固定的4096维特征向量)。
注意此时的CNN功能,因为输入数据格式仍是[images,labels],因此,fine-tuning后的CNN仍然只具有分类功能,不具备目标定位功能。
训练目标分类器SVMs
作者使用了IOU为候选区决定标签,并采用了网格搜索的方法测试了不同IOU选择下的模型的效果,论文中确定为IOU=0.3。详细内容在后面解释。利用微调后的AlexNet作为主干网络提取到候选区的4096维特征向量,搭配候选区的标签,就可以对每一个类训练一个SVM分类器。在论文中,使用了负例采样(hard negative mining method)进行训练模型,发现模型收敛很快。实际上为每一个类别训练线性 SVM的过程就是学习一个M*N维权重矩阵的过程,M为输入的特征向量的维度,N+1为类别的分类总数(包含背景)即线性SVM的个数。因此,将每一个特定类别的线性SVM分类器组合起来我们可以得到将不同目标物体划分开来的超平面。
训练目标回归器
为什么需要专门训练目标检测回归器呢?实际上,在之前的步骤我们已经可以实现简单的目标定位了,因为通过selective search我们已经可以得到图像上很多的目标候选区,然后借助于SVM分类器我们可以得到每一个候选区有没有目标物体,如果有是什么。因此知道了候选区的位置与可能包含的目标类别,再通过非极大值抑制方法滤除多余的候选框就可以将目标物体检测出来了。然而,这张方法中得到的定位信息基于selective search算法,而selective search算法是基于图片的颜色和纹理,最简单的理解就是容易受到光照的干扰,鲁棒性差,检测效果不好。
因此,论文中作者单独训练了目标回归器,在selective search算法得到的候选区的基础上修正定位信息,从而提高了目标定位的准确率。详细做法见后文。
R-CNN细节剖析
IOU解决类别标注问题
通过selective search得到候选区后,需要为候选区挂上标签。
- 只含有background信息的region proposal划分为background;
- 紧紧将目标物体围起来的region proposal根据目标类别可以简单划分;
- 目标物体的一部分与region proposal重合的如何标准呢?
作者使用了IOU,定义了一个IOU阈值,利用IOU评定region proposal与ground truth(真实的定位框)重合区域的大小,若大于阈值说明重合程度较大则判定为正样本,否则为负样本。那么,究竟选择什么IOU阈值合适呢?作者使用了网格搜索的方法尝试了不同的阈值,最终确定为0.3。网格搜索请见enginelong的博客
fine-tuning与SVM正负样本定义不一样的问题
可以简单解释为:
- 训练CNN时为了避免模型过拟合,因此将阈值设置为0.5,宽松一些以增加训练集的数量;
- 训练SVM时,正样本为ground truth,负样本定义为与ground truth的IOU小于0.3的候选区域为负样本,介于0.3与0.7之间的样本忽略。SVM对数据集的要求更为严格,然而事实上SVM适合于小数据集的训练。
非极大值抑制(NMS)
如上图需要定位一个车辆,使用算法找出了一堆的候选区,我们需要判别哪些矩形框是没用的。
非极大值抑制的方法是:先假设有6个候选框,根据分类器的类别分类概率做排序,假设从小到大属于车辆的概率 分别为A、B、C、D、E、F。
- 从最大概率矩形框F开始,分别判断A~E与F的重叠度IOU是否大于某个设定的阈值;
- 假设B、D与F的重叠度超过阈值,那么就扔掉B、D,保留F为第二个检测框。
- 从剩下的矩形框A、C、E中,选择概率最大的E,然后判断E与A、C的重叠度大于了一定的阈值,那么就扔掉A,C,保留E为第二个检测框,循环往复,直至找到所有有用的检测框。
训练目标回归器
使用线性分类器SVM为每一个region proposal打分之后,训练新的检测回归器修正使用selective search算法得到的定位信息。这里所谓的打分,就是分类正确的概率,即使用SVM正确预测候选区类别的概率。
训练目标回归器时,模型的输入数据是一系列的训练数据对,格式为:
{
(
P
i
,
G
i
)
}
\left\{\begin{matrix}(P^i, \end{matrix}G^i)\right\}
{(Pi,Gi)}
P
i
=
(
P
x
i
,
P
y
i
,
P
w
i
,
P
h
i
)
P^i = (P^i_x, P^i_y, P^i_w, P^i_h)
Pi=(Pxi,Pyi,Pwi,Phi)
G
=
(
G
x
,
G
y
,
G
w
,
G
h
)
G^ = (G_x, G_y, G_w, G_h)
G=(Gx,Gy,Gw,Gh)
P
i
P^i
Pi是region proposal的位置信息,
G
G
G为ground truth的定位信息,回归器的目标是去学习到将region proposal映射到ground truth的转换。作者构造了四个函数:
d
x
(
P
)
,
d
y
(
P
)
,
d
w
(
P
)
,
d
h
(
P
)
d_x(P), d_y(P), d_w(P), d_h(P)
dx(P),dy(P),dw(P),dh(P)
其中,
d
x
(
P
)
,
d
y
(
P
)
d_x(P), d_y(P)
dx(P),dy(P)指定了region proposal中心坐标尺度不变的转换方式,
d
w
(
P
)
,
d
h
(
P
)
d_w(P), d_h(P)
dw(P),dh(P)指定了基于对数空间的region proposal高和宽的转换方式
学习到这些函数之后,就可以将region proposal映射到最终预测的定位框
G
i
G^i
Gi:
G
^
x
=
P
w
d
x
(
P
)
+
P
x
\hat G_x = P_wd_x(P) + P_x
G^x=Pwdx(P)+Px
G
^
y
=
P
h
d
y
(
P
)
+
P
y
\hat G_y = P_hd_y(P) + P_y
G^y=Phdy(P)+Py
G
^
w
=
P
w
e
x
p
(
d
w
(
P
)
)
\hat G_w = P_wexp(d_w(P))
G^w=Pwexp(dw(P))
G
^
h
=
P
h
e
x
p
(
d
h
(
P
)
)
\hat G_h = P_hexp(d_h(P))
G^h=Phexp(dh(P))
作者将这四个函数建模为region proposals 的pool5特征(
φ
5
(
P
)
φ_5(P)
φ5(P))的线性函数(作者将pool5输出的特征假设是独立的)
进一步表示为:
d
∗
=
w
∗
T
φ
5
(
P
)
d_* = w^T_*φ_5(P)
d∗=w∗Tφ5(P)
其中,
w
∗
是
需
要
学
习
的
参
数
向
量
w_*是需要学习的参数向量
w∗是需要学习的参数向量
损失函数使用了均方差损失函数,搭配L2正则化即岭回归(enginelong的博客):
w
∗
=
a
r
g
m
i
n
∑
i
N
(
t
∗
i
−
w
∗
T
φ
5
(
P
i
)
)
2
+
λ
∣
∣
w
∗
∣
∣
2
w_* = argmin\sum_i^{N}(t^i_* - w^T_*φ_5(P^i))^2 + \lambda||w_*||^2
w∗=argmini∑N(t∗i−w∗Tφ5(Pi))2+λ∣∣w∗∣∣2
其中
t
∗
t_*
t∗为:
t
x
=
(
G
x
−
P
x
)
/
P
w
t_x = (G_x - P_x) / P_w
tx=(Gx−Px)/Pw
t
y
=
(
G
y
−
P
y
)
/
P
h
t_y = (G_y - P_y) / P_h
ty=(Gy−Py)/Ph
t
w
=
l
o
g
(
G
w
/
P
w
)
t_w = log(G_w/P_w)
tw=log(Gw/Pw)
t
h
=
l
o
g
(
G
h
/
P
h
)
t_h = log(G_h/P_h)
th=log(Gh/Ph)
这里有两个细节:
- 正则化权重 λ \lambda λ非常重要,论文中设置为1000;
- 根据直觉,如果region proposal与ground truth box重合度太低即IOU太小甚至为0,这种情况下强硬地去学习映射变换似乎似乎没有意义,因此,论文中作者设置了IOU阈值,选择出IOU最大的region proposal去学习向ground truth box的映射关系,小于阈值的直接丢弃。
当然,R-CNN原文中还有大量的细节,比如
- 为什么在fine tuning之后使用SVM;
- 以何种策略将region proposals转换为CNN输入格式;
- 运行时间分析(CNN提取的特征向量是低维的,速度较快);
- 如何巧妙地可视化卷积后的结果;
- 等等
关于作者的实验结果,请见R-CNN论文!
代码大致实现步骤
大数据集预训练
# Building 'AlexNet'
def create_alexnet(num_classes):
network = input_data(shape=[None, config.IMAGE_SIZE, config.IMAGE_SIZE, 3])
# 4维输入张量,卷积核个数,卷积核尺寸,步长
network = conv_2d(network, 96, 11, strides=4, activation='relu')
network = max_pool_2d(network, 3, strides=2)
# 数据归一化
network = local_response_normalization(network)
network = conv_2d(network, 256, 5, activation='relu')
network = max_pool_2d(network, 3, strides=2)
network = local_response_normalization(network)
network = conv_2d(network, 384, 3, activation='relu')
network = conv_2d(network, 384, 3, activation='relu')
network = conv_2d(network, 256, 3, activation='relu')
network = max_pool_2d(network, 3, strides=2)
network = local_response_normalization(network)
network = fully_connected(network, 4096, activation='tanh')
network = dropout(network, 0.5)
network = fully_connected(network, 4096, activation='tanh')
network = dropout(network, 0.5)
network = fully_connected(network, num_classes, activation='softmax')
momentum = tflearn.Momentum(learning_rate=0.001, lr_decay=0.95, decay_step=200)
network = regression(network, optimizer=momentum,
loss='categorical_crossentropy')
return network
模型fine tuning
# Use a already trained alexnet with the last layer redesigned
def create_alexnet(num_classes, restore=False):
# Building 'AlexNet'
network = input_data(shape=[None, config.IMAGE_SIZE, config.IMAGE_SIZE, 3])
network = conv_2d(network, 96, 11, strides=4, activation='relu')
network = max_pool_2d(network, 3, strides=2)
network = local_response_normalization(network)
network = conv_2d(network, 256, 5, activation='relu')
network = max_pool_2d(network, 3, strides=2)
network = local_response_normalization(network)
network = conv_2d(network, 384, 3, activation='relu')
network = conv_2d(network, 384, 3, activation='relu')
network = conv_2d(network, 256, 3, activation='relu')
network = max_pool_2d(network, 3, strides=2)
network = local_response_normalization(network)
network = fully_connected(network, 4096, activation='tanh')
network = dropout(network, 0.5)
network = fully_connected(network, 4096, activation='tanh')
network = dropout(network, 0.5)
# do not restore this layer
network = fully_connected(network, num_classes, activation='softmax', restore=restore)
network = regression(network, optimizer='momentum',
loss='categorical_crossentropy',
learning_rate=0.001)
return network
修改fine tuning网络提取特征向量
# Use a already trained alexnet with the last layer redesigned
# 减去softmax输出层,获得图片的特征
def create_alexnet():
# Building 'AlexNet'
network = input_data(shape=[None, config.IMAGE_SIZE, config.IMAGE_SIZE, 3])
network = conv_2d(network, 96, 11, strides=4, activation='relu')
network = max_pool_2d(network, 3, strides=2)
network = local_response_normalization(network)
network = conv_2d(network, 256, 5, activation='relu')
network = max_pool_2d(network, 3, strides=2)
network = local_response_normalization(network)
network = conv_2d(network, 384, 3, activation='relu')
network = conv_2d(network, 384, 3, activation='relu')
network = conv_2d(network, 256, 3, activation='relu')
network = max_pool_2d(network, 3, strides=2)
network = local_response_normalization(network)
network = fully_connected(network, 4096, activation='tanh')
network = dropout(network, 0.5)
network = fully_connected(network, 4096, activation='tanh')
network = regression(network, optimizer='momentum',
loss='categorical_crossentropy',
learning_rate=0.001)
return network
训练SVM分类器
# Construct cascade svms
def train_svms(train_file_folder, model):
files = os.listdir(train_file_folder)
svms = []
train_features = []
bbox_train_features = []
rects = []
for train_file in files:
if train_file.split('.')[-1] == 'txt':
pred_last = -1
pred_now = 0
X, Y, R = generate_single_svm_train(os.path.join(train_file_folder, train_file))
Y1 = []
features1 = []
Y_hard = []
features_hard = []
for ind, i in enumerate(X):
# extract features 提取特征
feats = model.predict([i])
train_features.append(feats[0])
# 所有正负样本加入feature1,Y1
if Y[ind]>=0:
Y1.append(Y[ind])
features1.append(feats[0])
# 对与groundtruth的iou>0.6的加入boundingbox训练集
if Y[ind]>0:
bbox_train_features.append(feats[0])
rects.append(R[ind])
# 剩下作为测试集
else:
Y_hard.append(Y[ind])
features_hard.append(feats[0])
tools.view_bar("extract features of %s" % train_file, ind + 1, len(X))
# 难负例挖掘
clf = SVC(probability=True)
# 训练直到准确率不再提高
while pred_now > pred_last:
clf.fit(features1, Y1)
features_new_hard = []
Y_new_hard = []
index_new_hard = []
# 统计测试正确数量
count = 0
for ind, i in enumerate(features_hard):
# print(clf.predict([i.tolist()])[0])
if clf.predict([i.tolist()])[0] == 0:
count += 1
# 如果被误判为正样本,加入难负例集合
elif clf.predict([i.tolist()])[0] > 0:
# 找到被误判的难负例
features_new_hard.append(i)
Y_new_hard.append(clf.predict_proba([i.tolist()])[0][1])
index_new_hard.append(ind)
# 如果难负例样本过少,停止迭代
if len(features_new_hard)/10<1:
break
pred_last = pred_now
# 计算新的测试正确率
pred_now = count / len(features_hard)
# print(pred_now)
# 难负例样本根据分类概率排序,取前10%作为负样本加入训练集
sorted_index = np.argsort(-np.array(Y_new_hard)).tolist()[0:int(len(features_new_hard)/10)]
for idx in sorted_index:
index = index_new_hard[idx]
features1.append(features_new_hard[idx])
Y1.append(0)
# 测试集中删除这些作为负样本加入训练集的样本。
features_hard.pop(index)
Y_hard.pop(index)
print(' ')
print("feature dimension")
print(np.shape(features1))
svms.append(clf)
# 将clf序列化,保存svm分类器
joblib.dump(clf, os.path.join(train_file_folder, str(train_file.split('.')[0]) + '_svm.pkl'))
# 保存boundingbox回归训练集
np.save((os.path.join(train_file_folder, 'bbox_train.npy')),
[bbox_train_features, rects])
# print(rects[0])
return svms
训练SVM回归器
# 训练boundingbox回归
def train_bbox(npy_path):
features, rects = np.load((os.path.join(npy_path, 'bbox_train.npy')))
# 不能直接np.array(),应该把元素全部取出放入空列表中。因为features和rects建立时用的append,导致其中元素结构不能直接转换成矩阵
X = []
Y = []
for ind, i in enumerate(features):
X.append(i)
X_train = np.array(X)
for ind, i in enumerate(rects):
Y.append(i)
Y_train = np.array(Y)
# 线性回归模型训练
clf = Ridge(alpha=1.0)
clf.fit(X_train, Y_train)
# 序列化,保存bbox回归
joblib.dump(clf, os.path.join(npy_path,'bbox_train.pkl'))
return clf
学习论文初级阶段,由于考研时间紧张,时间不充分肯定会有理解错误的地方,希望各位小伙伴批评指正!!!