BMN(Boundary Matching Network)代码解读

代码地址:https://github.com/JJBOY/BMN-Boundary-Matching-Network
该代码由JJBOY借鉴论文作者的上一工作BSN的源码写成

本人对代码的理解主要写在了注释中

该代码以main.py中main函数为入口,根据命令行参数,执行不同的函数。先看opt[“mode”] == “train”

BMN_Train(opt)

该函数中首先创建了BMN对象,所以先来看BMN类的代码(models.py中)

BMN(opt)

构造函数接受一个参数字典opt,设置各参数,然后调用了_get_interp1d_mask(),之后是构建网络层。

先来看_get_interp1d_mask()函数,该函数生成了论文中的sampling mask weight W∈ R(NTDT)。函数中穷举了所有可能的提议,将每个提议的区间范围扩充,然后调用_get_interp1d_bin_mask函数为每个提议生成相应的w(i,j)∈ R(NT)

_get_interp1d_mask(…)

        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])

_get_interp1d_bin_mask(…)

    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是一个关键类,进入到该类的代码

VideoDataSet(data.Dataset)

构造函数

    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:标注信息,…}

_getDatasetDict()

    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()

def _get_train_label(self, index, anchor_xmin, anchor_xmax)

首先是读取出一些信息,并使用“特征帧数/总帧数*总时长”得到有效时长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的代码与其基本一致。

iou_with_anchors(anchors_min, anchors_max, box_min, box_max)

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(筛选提议)代码,没啥好说的,略。

你可能感兴趣的:(BMN(Boundary Matching Network)代码解读)