代码地址:https://github.com/JJBOY/BMN-Boundary-Matching-Network
该代码由JJBOY借鉴论文作者的上一工作BSN的源码写成
本人对代码的理解主要写在了注释中
该代码以main.py中main函数为入口,根据命令行参数,执行不同的函数。先看opt[“mode”] == “train”
该函数中首先创建了BMN对象,所以先来看BMN类的代码(models.py中)
构造函数接受一个参数字典opt,设置各参数,然后调用了_get_interp1d_mask(),之后是构建网络层。
先来看_get_interp1d_mask()函数,该函数生成了论文中的sampling mask weight W∈ R(NTDT)。函数中穷举了所有可能的提议,将每个提议的区间范围扩充,然后调用_get_interp1d_bin_mask函数为每个提议生成相应的w(i,j)∈ R(NT)
def _get_interp1d_mask(self):
# generate sample mask for each boundary-matching pair
mask_mat = []
for end_index in range(self.tscale): # 视频特征序列长度为tscale(100)
mask_mat_vector = []
for start_index in range(self.tscale): # tscale次循环
if start_index <= end_index: # 穷举时段 对每一个时段划分num_sample个proposal,再对每个proposal采样num_sample_perbin个点
p_xmin = start_index
p_xmax = end_index + 1 # +1?
center_len = float(p_xmax - p_xmin) + 1 # 长度 center? +1?
sample_xmin = p_xmin - center_len * self.prop_boundary_ratio # 区间向左右各扩展了总长度的prop_boundary_ratio(0.5)
sample_xmax = p_xmax + center_len * self.prop_boundary_ratio # 论文中prop_boundary_ratio为0.25
p_mask = self._get_interp1d_bin_mask( # shape:(tscale1-100,num_sample-32)
sample_xmin, sample_xmax, self.tscale, self.num_sample, self.num_sample_perbin)
else:
p_mask = np.zeros([self.tscale, self.num_sample])
mask_mat_vector.append(p_mask)
# print(len(mask_mat_vector)) # tscale2个(tscale1,num_sample)的数组
mask_mat_vector = np.stack(mask_mat_vector, axis=2) #(tscale1,num_sample,tscale2)
mask_mat.append(mask_mat_vector)
# print(len(mask_mat)) # tscale3个(tscale1,num_sample,tscale2)的数组
mask_mat = np.stack(mask_mat, axis=3) # (tscale1,num_sample,tscale2,tscale3)
mask_mat = mask_mat.astype(np.float32) # 生成W(i,j)∈ R(N*T*D*T) shape :[100,32,100,100]
# nn.Parameter是继承自torch.Tensor的子类,其主要作用是作为nn.Module中的可训练参数使用。
# 它与torch.Tensor的区别就是nn.Parameter会自动被认为是module的可训练参数,即加入到parameter()这个迭代器中去;
# 而module中非nn.Parameter()的普通tensor是不在parameter中的。
# nn.Parameter的对象的requires_grad属性的默认值是True,即是可被训练的,这与torth.Tensor对象的默认值相反
# torch.Tensor是默认的tensor类型(torch.FlaotTensor)的简称。 一个张量tensor可以从Python的list或序列构建
# view返回一个有相同数据但大小不同的tensor -1表示该维度值根据数据总数和另一个维度值得到(除法)
self.sample_mask = nn.Parameter(torch.Tensor(mask_mat).view(self.tscale, -1), requires_grad=False)
# torch.Size([100, 320000])
def _get_interp1d_bin_mask(self, seg_xmin, seg_xmax, tscale, num_sample, num_sample_perbin):
# generate sample mask for a boundary-matching pair
# num_sample为采样点数 num_sample_perbin为对每个采样点再细分的点数 共 32*3=96个点
# 此处是 使用每个大采样点对应的小采样点 生成该大采样点的w(i,j)
plen = float(seg_xmax - seg_xmin) # 扩展后的长度
plen_sample = plen / (num_sample * num_sample_perbin - 1.0) # 每“小段”样本长
total_samples = [seg_xmin + plen_sample * ii for ii in range(num_sample * num_sample_perbin)] # 所有采样点
p_mask = []
# 使用每个大采样点对应的小采样点 生成该大采样点的w(i,j) 共num_sample个
for idx in range(num_sample):
bin_samples = total_samples[idx * num_sample_perbin:(idx + 1) * num_sample_perbin] # 切片出每个proposal的采样点
bin_vector = np.zeros([tscale]) # size=tscale??
for sample in bin_samples: # 参照论文 w(i,j,n)[t]的生成
sample_upper = math.ceil(sample) # 向上取整
sample_decimal, sample_down = math.modf(sample) # 返回sample的整数部分与小数部分 左小右整
if int(sample_down) <= (tscale - 1) and int(sample_down) >= 0:
bin_vector[int(sample_down)] += 1 - sample_decimal
if int(sample_upper) <= (tscale - 1) and int(sample_upper) >= 0:
bin_vector[int(sample_upper)] += sample_decimal
bin_vector = 1.0 / num_sample_perbin * bin_vector # 除以取样数
p_mask.append(bin_vector) # 最终变为包含num_sample个长度为tscale(100)的列表的列表,即(num_sample,100)
p_mask = np.stack(p_mask, axis=1) # axis=1 即将num_sample个列表(对应元素)堆叠,得100个长度为num_sample的数组,即(100,num_sample )
return p_mask # 生成w[i,j]∈ R(N*T) shape :[100,32]
回到BMN的构造函数,结合forward()函数看网络层的设置。该网络层设置与论文中Table1给出的略有不同:一是Base Module的x_1d_b中第二次卷积,论文中是使维度变为128 而非保持256不变;二是多了Proposal Evaluation Module的x_1d_p;三是Proposal Evaluation Module的x_2d_p,相对论文中,多了一组“nn.Conv2d(self.hidden_dim_2d, self.hidden_dim_2d, kernel_size=3, padding=1), nn.ReLU(inplace=True)”。 数据在网络传导过程中的形状变化见forward中注释。
def forward(self, x): # x: torch.Size([8, 400, 100])
base_feature = self.x_1d_b(x) # torch.Size([8, 256, 100])
start = self.x_1d_s(base_feature).squeeze(1) # squeeze(1) 当第二个维度值为1时 去除该维度
end = self.x_1d_e(base_feature).squeeze(1) # torch.Size([8, 1, 100])变为torch.Size([8, 100])
confidence_map = self.x_1d_p(base_feature) # torch.Size([8, 256, 100]) S(F) ∈ R(C×T)
confidence_map = self._boundary_matching_layer(confidence_map) # torch.Size([8, 256, 32, 100, 100])
confidence_map = self.x_3d_p(confidence_map).squeeze(2) # torch.Size([8, 512, 1, 100, 100])变为torch.Size([8, 512, 100, 100])
confidence_map = self.x_2d_p(confidence_map) # torch.Size([8, 2, 100, 100])
return confidence_map, start, end
至此,BMN类的代码已看完。接着回到BMN_Train函数
def BMN_Train(opt):
model = BMN(opt) # 首先创建BMN对象
model = torch.nn.DataParallel(model, device_ids=[0]).cuda() # 设置多卡训练(但本机单卡)
# filter过滤掉requires_grad==False,即不需要计算梯度的parameter
optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=opt["training_lr"],
weight_decay=opt["weight_decay"])
# DataLoader的第一个参数可以是map-style或iterable-style的datasets
# 这里的VideoDataSet即是map-style(需要有__getitem__() and __len__()方法)
train_loader = torch.utils.data.DataLoader(VideoDataSet(opt, subset="train"),
batch_size=opt["batch_size"], shuffle=True,
num_workers=8, pin_memory=True)
# num_workers 决定了有几个进程来处理data loading。0意味着所有的数据都会被load进主进程。(默认为0)
# pin_memory如果设置为True,那么data loader将会在返回它们之前,将tensors拷贝到CUDA中的固定内存(CUDA pinned memory)中
test_loader = torch.utils.data.DataLoader(VideoDataSet(opt, subset="validation"),
batch_size=opt["batch_size"], shuffle=False,
num_workers=8, pin_memory=True)
# 调整学习率机制 每过step_size个epoch lr=lr*gamma
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=opt["step_size"], gamma=opt["step_gamma"])
bm_mask = get_mask(opt["temporal_scale"]) # [tscale, tscale]的tensor,上三角全1 下三角全0
for epoch in range(opt["train_epochs"]):
# warning: calling scheduler.step() before calling optimizer.step()) will skip the first value of the learning rate schedule
# may be unable to reproduce results
scheduler.step()
train_BMN(train_loader, model, optimizer, epoch, bm_mask)
test_BMN(test_loader, model, epoch, bm_mask)
其中作为DataLoader参数的VideoDataSet是一个关键类,进入到该类的代码
def __init__(self, opt, subset="train"):
# 此处略去一些属性设置
self._getDatasetDict() # 提取anno_database中属于当前集合(self.subset)的数据 dict:{id:标注信息,...}
# r(tn) = [tn−df/2, tn+df/2], where df=tn−tn−1 is the temporal interval between two locations.
# 共100个时间点 df=1
# 0-1标准化后的每个候选点扩充后的区间左右端点值列表:
self.anchor_xmin = [self.temporal_gap * (i - 0.5) for i in range(self.temporal_scale)] # [-0.005, 0.005, 0.015, 0.025, 0.035,...,0.925, 0.935, 0.9450000000000001, 0.9550000000000001, 0.965, 0.975, 0.985]
self.anchor_xmax = [self.temporal_gap * (i + 0.5) for i in range(self.temporal_scale)] # [ 0.005, 0.015, 0.025, 0.035, 0.045,...,0.935, 0.9450000000000001, 0.9550000000000001, 0.965, 0.975, 0.985, 0.995]
其中self._getDatasetDict()用于提取anno_database中属于当前集合(self.subset)的数据 dict:{id:标注信息,…}
def _getDatasetDict(self):
anno_df = pd.read_csv(self.video_info_path) # DataFrame shape:(19228, 7) 包含id和所属集合等
anno_database = load_json(self.video_anno_path) # dict len:19228 包含id和标注信息等
self.video_dict = {}
for i in range(len(anno_df)): # 将anno_database中属于当前集合(self.subset)的数据 放入self.video_dict
video_name = anno_df.video.values[i] # anno_df的“video”列的第i个值 video即id/name
video_info = anno_database[video_name] # id相应的标注信息
video_subset = anno_df.subset.values[i] # anno_df的“subset”列的第i个值
if self.subset in video_subset: # e.g. if "train" in "training":
self.video_dict[video_name] = video_info
self.video_list = list(self.video_dict.keys()) # id列表
print("%s subset video numbers: %d" % (self.subset, len(self.video_list)))
分割线——————————————————————————————————
在前面BMN_Train部分代码的注释中提到:DataLoader的第一个参数可以是map-style或iterable-style的datasets;这里的VideoDataSet即是map-style(需要有__getitem__() and len()方法)。VideoDataSet中实现的__getitem__() 函数,当self.mode == "train"时,返回特征数据、置信图、起点得分、终点得分;否则返回索引值和特征数据。
getitem() 函数中主要涉及_load_file()和_get_train_label()两个函数,分别用于获得特征数据和标签。其中_load_file()的代码主要是对数据的读取与转换,无甚要点。下面主要看_get_train_label()
首先是读取出一些信息,并使用“特征帧数/总帧数*总时长”得到有效时长corrected_second。但我发现有些feature_frame>video_frame。。
# change the measurement from second to percentage
gt_bbox = [] # 存放该视频中若干个动作实例的起点终点对
for j in range(len(video_labels)):
tmp_info = video_labels[j]
# 若相应时间点不超过有效总时长 则tmp_* = tmp_info['segment'][x] / corrected_second
# 0-1标准化 (将度量值从秒变为百分数)
tmp_start = max(min(1, tmp_info['segment'][0] / corrected_second), 0)
tmp_end = max(min(1, tmp_info['segment'][1] / corrected_second), 0)
gt_bbox.append([tmp_start, tmp_end])
# generate R_s and R_e
gt_bbox = np.array(gt_bbox) # shape (n,2),n为segment个数
gt_xmins = gt_bbox[:, 0] # (n,) 长度为n的一维数组
gt_xmaxs = gt_bbox[:, 1]
# for a ground-truth action instance φg=(ts,te) with duration dg = te−ts
# we denote its starting and ending regions as rS=[ts−dg/10,ts+dg/10] and rE=[te−dg/10,te+dg/10]
# 而下面采用了定长gt_len_small=0.03 即将每个点向左右扩充0.015得到相应区间 略有问题
gt_lens = gt_xmaxs - gt_xmins
gt_len_small = 3 * self.temporal_gap # np.maximum(self.temporal_gap, self.boundary_ratio * gt_lens)
# 两个一维array组成的tuple 通过np.stack 得到(n,2)的二维array n为segment个数
gt_start_bboxs = np.stack((gt_xmins - gt_len_small / 2, gt_xmins + gt_len_small / 2), axis=1)
gt_end_bboxs = np.stack((gt_xmaxs - gt_len_small / 2, gt_xmaxs + gt_len_small / 2), axis=1)
gt_iou_map = np.zeros([self.temporal_scale, self.temporal_scale])
for i in range(self.temporal_scale): # 穷举所有可能的区间,计算与当前实例的每个真实区间的交并比
for j in range(i, self.temporal_scale):
gt_iou_map[i, j] = np.max( # np.max取返回的一维数组中的最大值
iou_with_anchors(i * self.temporal_gap, (j + 1) * self.temporal_gap, gt_xmins, gt_xmaxs))
# 参数依次为:候选区间左端点、候选点区间右端点 真实起点列表 真实终点列表
gt_iou_map = torch.Tensor(gt_iou_map)
# 计算每个候选点扩充后的区间 与真实点扩充后区间的IoR
match_score_start = []
for jdx in range(len(anchor_xmin)):
match_score_start.append(np.max(
ioa_with_anchors(anchor_xmin[jdx], anchor_xmax[jdx], gt_start_bboxs[:, 0], gt_start_bboxs[:, 1])))
# 参数依次为:候选点扩充后左端点、候选点扩充后右端点 所有真实“起“点扩充后的左端点列表 和右端点列表
match_score_end = []
for jdx in range(len(anchor_xmin)):
match_score_end.append(np.max(
ioa_with_anchors(anchor_xmin[jdx], anchor_xmax[jdx], gt_end_bboxs[:, 0], gt_end_bboxs[:, 1])))
# 参数依次为:候选点扩充后左端点、候选点扩充后右端点 所有真实”终“点扩充后的左端点列表 和右端点列表
match_score_start = torch.Tensor(match_score_start) # torch.Size([100])
match_score_end = torch.Tensor(match_score_end) # torch.Size([100])
return match_score_start, match_score_end, gt_iou_map
其中用于计算区间IoU和IoA(论文中为IoR,但实际计算方式似乎一样)的函数位于utils.py中。下面仅看iou_with_anchors()函数代码,因为ioa_with_anchors的代码与其基本一致。
def iou_with_anchors(anchors_min, anchors_max, box_min, box_max):
""" 计算提议区间(anchors_min, anchors_max)与真实区间的交并比
box_min 为真实区间的起点构成的数组,box_max 为真实区间的终点构成的数组
"""
len_anchors = anchors_max - anchors_min
# 两个区间(s1,e1)和(s2,e2)的交集 为较大的起点max(s1,s2)和较小的终点min(e2,e2)所构成的区间
# 若min(e2,e2)<=max(s1,s2) 说明无交集
# np.maximum用于逐元素比较两个array的大小 选择最大值
int_xmin = np.maximum(anchors_min, box_min) # 取较大的起点
int_xmax = np.minimum(anchors_max, box_max) # 取较小的终点
inter_len = np.maximum(int_xmax - int_xmin, 0.) # 计算交集大小 若<0 说明无交集,取0
union_len = len_anchors + box_max - box_min - inter_len# 并集大小=两集合大小之和-交集大小
jaccard = np.divide(inter_len, union_len)
return jaccard # 返回一个一维数组(长度为真实区间(segment)个数)
至此,VideoDataSet类相关代码介绍完毕。当使用VideoDataSet对象构建DataLoader后,就可以以如下方式获取数据。
for n_iter, (input_data, label_confidence, label_start, label_end) in enumerate(data_loader):
训练过程就是每次使用上面的方法获取数据,并将特征数据input_data输入到网络,经过forward获得输出的置信图,起点得分值,终点得分值。再和真实的置信图,起点得分值,终点得分值一起送入bmn_loss_func函数,计算损失值。然后通过反向传播,迭代优化(调用torch几个函数而已)
至于损失函数、BMN_inference(生成提议)、BMN_post_processing(筛选提议)代码,没啥好说的,略。