1. 前言
SOT的思想是,在视频中的某一帧中框出你需要跟踪目标的bounding box,在后续的视频帧中,无需你再检测出物体的bounding box进行匹配,而是通过某种相似度的计算,寻找需要跟踪的对象在后续帧的位置,如下动图所示(图中使用的是本章所讲siameseFC的升级版siameseMask),常见的经典的方法有KCF[4]等。
作者提出的siameseFC网络兼顾了追踪的速度和精度,在当初多个benchmarks中达到了最优(SOTA)。论文中,作者提到:
The key contribution of this paper is to demonstrate that this approach achieves very competitive performance in modern tracking benchmarks at speeds that far exceed the frame-rate requirement.
那么这个孪生网络siamese network到底是什么呢?
常见的孪生网络的结构
答:简单来说,孪生网络siamese network主要用来衡量两个输入的相似程度。如上图,孪生神经网络有两个输入(Input1 and Input2),将两个输入输入到两个神经网络(Network1 and Network2,其实是共享权重的,本质上是同一个网络),这两个神经网络分别将输入映射到新的空间,形成输入在新的空间中的表示。最后通过Loss的计算,评价两个输入的相似度。
那么本文中提出的全卷积的siamese network本质上也是寻找给定的模板图像z(论文中的exemplar image,类似于你在测试时框出的第一个bounding box中的图像)在搜索图像x(search image,其他视频帧)上的位置,从而实现单目标追踪。
这其实是类似于人的视觉感官常识的,人在进行目标追踪的时候,前一秒记住该目标的一些特征(身高,长相,衣着等),后一秒在另一张视频帧中根据这些特征找到该目标。
上面结构图中,siamese网络由以下几部分构成:
,大小为6x6x128以及
,大小为22x22x128
(论文中的cross-correlation),实质上是以
为特征图,以
为卷积核进行的卷积互运算
得到了score map后,我们知道score map反应了
和
中每个对应部分的相似度关系。score越大,相似度越大,越有可能是同一个物体。
上述互相关运算的步骤,像极了我们手里拿着一张目标的照片(模板图像),然后把这个照片按在需要寻找目标的图片上(搜索图像)进行移动,然后求重叠部分的相似度,从而找到这个目标,只不过为了计算机计算的方便,使用AlexNet对图像数据进行了编码/特征提取。这里我引用了某版本pytorch的siamFC代码中的动图[5],如下,生动形象地将siameseFC的互相关运算描述出来了。
我们对siamese的结构大致就讲完了,还有一些内容结合代码来讲,效果更好。
代码我是找了一个基于pytorch版本(星星不是很多,不过看起来比较简单)的,链接如下:
https://github.com/mozhuangb/SiameseFC-pytorch
2. SiameseFC网络结构定义
上面我们讲到SiameseFC的结构,主要由以下几部分构成
AlexNet网络代码定义如下:
class AlexNet(nn.Module):
def __init__(self):
super(AlexNet, self).__init__()
self.conv1 = nn.Sequential(
nn.Conv2d(3, 96, 11, stride=2, padding=0),
nn.BatchNorm2d(96),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2, padding=0))
self.conv2 = nn.Sequential(
nn.Conv2d(96, 256, 5, stride=1, padding=0, groups=2),
nn.BatchNorm2d(256),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2, padding=0))
self.conv3 = nn.Sequential(
nn.Conv2d(256, 384, 3, stride=1, padding=0),
nn.BatchNorm2d(384),
nn.ReLU(inplace=True))
self.conv4 = nn.Sequential(
nn.Conv2d(384, 384, 3, stride=1, padding=0, groups=2),
nn.BatchNorm2d(384),
nn.ReLU(inplace=True))
self.conv5 = nn.Sequential(
nn.Conv2d(384, 256, 3, stride=1, padding=0, groups=2))
def forward(self, x):
conv1 = self.conv1(x)
conv2 = self.conv2(conv1)
conv3 = self.conv3(conv2)
conv4 = self.conv4(conv3)
conv5 = self.conv5(conv4)
return conv5
该部分为图像特征提取网络,不过多进行解析,接着根据AlexNet定义SiameseFC网络:
class Siamfc(nn.Module):
def __init__(self, branch):
super(Siamfc, self).__init__()
self.branch = branch
self.bn_adjust = nn.Conv2d(1, 1, 1, stride=1, padding=0)
self._initialize_weights()
def _initialize_weights(self):
for m in self.modules():
if isinstance(m, nn.Conv2d):
n = m.kernel_size[0] * m.kernel_size[1] * m.out_channels
m.weight.data.normal_(0, math.sqrt(2. / n))
if m.bias is not None:
m.bias.data.zero_()
elif isinstance(m, nn.BatchNorm2d):
m.weight.data.fill_(1)
m.bias.data.zero_()
def Xcorr(self, x, z): # x denote search, z denote template
out = []
for i in range(x.size(0)):
out.append(F.conv2d(x[i, :, :, :].unsqueeze(0), z[i, :, :, :].unsqueeze(0)))
return torch.cat(out, dim=0)
def forward(self, x, z): # x denote search, z denote template
x = self.branch(x)
z = self.branch(z)
xcorr_out = self.Xcorr(x, z)
out = self.bn_adjust(xcorr_out)
return out
可见Siamfc类中self.branch即为AlexNet特征提取网络,输入模板图像z和搜索图像x经过selg.branch(AlexNet)提取特征后,作为 self.Xcorr 输入,进行互运算。这里的self.Xcorr定义如下:
def Xcorr(self, x, z): # x denote search, z denote template
out = []
for i in range(x.size(0)):
out.append(F.conv2d(x[i, :, :, :].unsqueeze(0), z[i, :, :, :].unsqueeze(0)))
return torch.cat(out, dim=0)
很明显,就是一个卷积互运算的定义。最后模型在最后一层,添加了一个1x1卷积层self.bn_adjust(输入输出维度都是1) 。
结合代码一看,说真的,siameseFC结构比较简单(所以人家速度快!)
3. siameseFC的输入、输出和损失函数
想了解一个网络,不光要知道网络的结构,还得需要知道网络的输入和输出(预测值和真实值label)是什么,然后根据网络输出的预测值和真实值来计算损失函数。那么我们将按照siameseFC网络的输入、输出和损失函数分别进行讲解。
3.1 siameseFC网络的输入
上面我们知道siameseFC每次需要两张图片作为输入,一张是所谓的模板图像z,另一张是搜索图像x。模板图像需要缩放到127x127大小,搜素图像需要缩放到255x255大小。那么关于模板图像和搜索图像的选取和位置放置,有什么需要注意的呢?下面分别从论文和代码的角度进行分析。
论文中给出一组图,如下
上方三张图为模板图像z,下面三张图为搜索图像x。这里的模板图像z好像和想象中有点不一样,本以为截取bounding box内的图像内容作为模板图像z的,但是这里看来,好像还包含了bounding box周围的一些环境信息和padding。
论文中是这么说的:
意思就是在bounding box加上了周围的一些边界信息,上下左右各加上p个像素信息。其中:
假设bounding box的大小为(w,h),加上边界后的大小为(w+2p,h+2p)。对于模板图像而言,还需要对其加上边界后的结果乘上一个缩放系数s,使得区域的面积为127x127,当然了,这里缩放后的长宽不一定非要等于127,那么公式
成立。
然后呢,会发现虽然现在区域面积为127x127,但是他并不是127x127的方形,所以需要进行resize。
如果你还是不太清楚,那我们来看一下代码上是怎么做的。这种基于pytorch的代码,一般获取和处理数据,都定义在数据集定义中的__getitem__类方法中。这里我们到dataset.py中的Pair类的__getitem__中,找到如下:
crop_z = self.crop(
img_z, bndbox_z, self.exemplarSize
) # crop template patch from img_z, then resize [127, 127]
crop_x = self.crop(
img_x, bndbox_x, self.instanceSize
) # crop search patch from img_x, then resize [255, 255]
这里的img_z 和 bndbox_z和 img_x和 bndbox_x分别为模板图像z和搜索图像x以及他们中物体所出bounding box的坐标(左上横坐标x,左上纵坐标y,宽,高)。
这里的self.crop定义如下:
# crop the image patch of the specified size - template(127), search(255)
def crop(self, image, bndbox, out_size):
center = bndbox[:2] + bndbox[2:] / 2
size = bndbox[2:]
context = self.context * size.sum() #(w+h)/2
patch_sz = out_size / self.exemplarSize * \
np.sqrt((size + context).prod())
return crop_pil(image, center, patch_sz, out_size=out_size)
context就是添加边界的宽度,即上述公式中的2p。这里又跳入函数crop_pil中,定义如下:
def crop_pil(image, center, size, padding='avg', out_size=None):
# convert bndbox to corners
size = np.array(size)
corners = np.concatenate((center - size / 2, center + size / 2)) #(左上和右下)
corners = np.round(corners).astype(int)
pads = np.concatenate((-corners[:2], corners[2:] - image.size)) #填充原图,防止后面裁剪的时候出现越界现象
npad = max(0, int(pads.max()))
if npad > 0:#大于0则需要进行pad
image = pad_pil(image, npad, padding=padding)
corners = tuple((corners + npad).tolist())
patch = image.crop(corners)
if out_size is not None:
if isinstance(out_size, numbers.Number):
out_size = (out_size, out_size)
if not out_size == patch.size:
patch = patch.resize(out_size, Image.BILINEAR)
return patch
主要完成了两个操作,分别为:
1. 填充(padding),如果需要添加边界的部分超出了原图的大小(一般出现在bounding box靠近图像边界的情况),则使用像素的均值代替。论文中这么说的:
When a sub-window extends beyond the extent of the image, the missing portions are lled with the mean RGB value
2. 缩放,这里用resize做的
同理对搜索图像x也做类似的操作,然后你会发现一个很有趣的事。
此时每对模板图像和搜索图像中心就是物体的bounding box中心!
这有什么好处呢? 答:我们在训练的时候,每次把模板图像滑到搜索图像的中心的时候,这时候会有最大的相似度,这为网络输出的真实值/标签设置,省了很多功夫!
到这里,siamese的输入就讲解结束了。下面介绍siamese的输出(真实值label)。
3.2 siameseFC的真实标签值
上面我们说到每对模板图像和搜索图像中心就是物体的bounding box中心!这为label设置提供了很大的便利。回到__getitem__中,获取label和weight的代码如下:
labels, weights = self.create_labels() # create corresponding labels and weights
其中 self.create_labels()定义如下:
# create labels and weights. This section is similar to Matlab version of Siamfc
def create_labels(self):
labels = self.create_logisticloss_labels()
weights = np.zeros_like(labels)
pos_num = np.sum(labels == 1)
neg_num = np.sum(labels == 0)
weights[labels == 1] = 0.5 / pos_num
weights[labels == 0] = 0.5 / neg_num
#weights *= pos_num + neg_num
labels = labels[np.newaxis, :]
weights = weights[np.newaxis, :]
return labels, weights
def create_logisticloss_labels(self):
label_sz = self.scoreSize #17
r_pos = self.rPos / self.totalStride #16/8=2
r_neg = self.rNeg / self.totalStride #0
labels = np.zeros((label_sz, label_sz))
for r in range(label_sz):
for c in range(label_sz):
dist = np.sqrt((r - label_sz // 2)**2 + (c - label_sz // 2)**2)
if dist <= r_pos:
labels[r, c] = 1
elif dist <= r_neg:
labels[r, c] = self.ignoreLabel
else:
labels[r, c] = 0
return labels
这里我们把一些默认值带入,获得labels的数值。如下:
array([[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 1., 1., 1., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 1., 1., 1., 1., 1., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 1., 1., 1., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]])
这不中心区域都是1,其他都为0嘛!这意味着当模板图像移动到搜索图像的中心时,获得最大相似度,即匹配成功!
权重的定义也很简单,这里就不展示了。
当输入,输出标签获得了,通过网络前向过程的输出(预测值)和真实值(标签)就可以设置损失函数了。当然,这里的损失函数也很简单。
3.3 损失函数
论文中的损失函数部分如下展示:
其实就是利用logisitic loss 来计算预测的scores map和真实的scores map中所有对应点的损失和,然后取平均即可。
代码实现更为简单了,如下:
criterion = BCEWeightLoss() # define criterion
output = model(search, template)
loss = criterion(output, labels, weights)/template.size(0)
至此损失函数部分就讲完了,siameseFC主体部分就聊完了。接下来我们将解析最后一个部分:实际使用中siameseFC的tracking部分。
4. siameseFC的tracking部分
如下为论文中对siameseFC的tracking部分就行的讲解。
大致的意思就是将网络输出的scores map通过插值的上采样法,将scores map从17x17放大至272x272。代码中是这样实现的:
scores_up = cv2.resize(scores, (config.final_sz, config.final_sz), interpolation=cv2.INTER_CUBIC) # [257,257,3]
同时为了找到尺度合适的候选区域,代码中完成了以下步骤,相关注释都给出了,不过多讲解:
scores_ = np.squeeze(scores_up)
# penalize change of scale
scores_[0, :, :] = config.scalePenalty * scores_[0, :, :]
scores_[2, :, :] = config.scalePenalty * scores_[2, :, :]
# find scale with highest peak (after penalty)
new_scale_id = np.argmax(np.amax(scores_, axis=(1, 2)))
# update scaled sizes
x_sz = (1 - config.scaleLR) * x_sz + config.scaleLR * scaled_search_area[new_scale_id]
target_w = (1 - config.scaleLR) * target_w + config.scaleLR * scaled_target_w[new_scale_id]
target_h = (1 - config.scaleLR) * target_h + config.scaleLR * scaled_target_h[new_scale_id]
# select response with new_scale_id
score_ = scores_[new_scale_id, :, :]
score_ = score_ - np.min(score_)
score_ = score_ / np.sum(score_)
# apply displacement penalty
score_ = (1 - config.wInfluence) * score_ + config.wInfluence * penalty
p = np.asarray(np.unravel_index(np.argmax(score_), np.shape(score_))) # position of max response in score_
center = float(config.final_sz - 1) / 2 # center of score_
disp_in_area = p - center
disp_in_xcrop = disp_in_area * float(config.totalStride) / config.responseUp
disp_in_frame = disp_in_xcrop * x_sz / config.instanceSize
pos_y, pos_x = pos_y + disp_in_frame[0], pos_x + disp_in_frame[1]
bboxes[f, :] = pos_x - target_w / 2, pos_y - target_h / 2, target_w, target_h
至此,siameseFC的tracking部分就讲完了!siameseFC的解析就告一段落了!
5. 总结
后面,我将接着siamese家族用在目标追踪领域上的其他成果,例如siameseRPN,siameseMask等进行讲解,继续分析这个类型网络的魅力所在。另外,我将解析更多的和目标追踪领域有关的论文和代码,希望大家支持!
参考:
[1] https://zhuanlan.zhihu.com/c_1166445784311758848
[2] https://zhuanlan.zhihu.com/c_1177216807848529920
[3] http://xxx.itp.ac.cn/abs/1606.09549
[4] Henriques, J.F., Caseiro, R., Martins, P., Batista, J.: High-speed tracking with kernelized correlation lters. PAMI 37(3) (2015) 583{596
[5] https://github.com/rafellerc/Pytorch-SiamFC
作者:知乎—周威
作者地址:https://www.zhihu.com/people/zhou-wei-37-26