deepsort--从sort到deepsort

644 阅读13分钟

from:目标跟踪:Deepsort--卡尔曼滤波、匈牙利匹配、马氏距离、欧氏距离、级联匹配、reid

from:【MOT】详解DeepSORT多目标追踪模型

Sort的整体流程

无标题.png

基于上图展开对sort的理解攻势:

  1. 给定视频原始帧(假设第一帧就有目标框);
  2. 运行目标检测器进行检测,获取目标检测框,分配初始轨迹;
  3. 轨迹卡尔曼滤波预测;
  4. 使用匈牙利算法将预测后的轨迹(tracks)和当前帧中的检测(detecions)进行匹配(IOU匹配);
  5. 卡尔曼滤波更新;

上述过程应该只有4这个过程承上启下,理解模糊。以下进行详细解释:

匈牙利算法解决的是一个分配问题,在MOT的主要步骤中用马氏距离计算相似度(我记得是.jpg),得到了前后两帧的相似度矩阵。匈牙利算法就是通过求解这个相似度矩阵(iou),从而解决前后两帧真正匹配的目标。这部分 sklearn 库有对应的函数 linear_assignment 来进行求解。

SORT 算法中是通过前后两帧 IoU 来构建相似度矩阵,所以 SORT 计算速度非常快。

检测(detections)是通过目标检测器得到的目标框,轨迹(Tracks)是一段轨迹。核心是匹配的过程与卡尔曼滤波的预测和更新过程。

DeepSort的整体流程

22.png

DeepSort中的deep

DeepSORT是SORT算法中的升级版本。那这个Deep的意思是该算法中使用到了Deep Learning网络。

那么相比于SORT算法,DeepSORT到底做了哪部分的改进呢?

SORT算法的缺陷:频繁的ID switch

SORT算法利用卡尔曼滤波算法预测检测框在下一帧的状态,将该状态下一帧的检测结果进行匹配,实现车辆的追踪。

那么这样的话,一旦物体受到遮挡或者其他原因没有被检测到,卡尔曼滤波预测的状态信息将无法和检测结果进行匹配,该追踪片段将会提前结束。

遮挡结束后,车辆检测可能又将被继续执行,那么SORT只能分配给该物体一个新的ID编号,代表一个新的追踪片段的开始。所以SORT的缺点是

受遮挡等情况影响较大,会有大量的ID切换

用特征提取改进

那么如何解决SORT算法出现过多的ID切换呢?毕竟是online tracking,不能利用全局的视频帧的检测框数据,想要缓解拥堵造成的ID切换需要利用到前面已经检测到的物体的外观特征(假设之前被检测的物体的外观特征都被保存下来了),那么当物体收到遮挡后到遮挡结束,我们能够利用之前保存的外观特征分配该物体受遮挡前的ID编号,降低ID切换。

当然DeepSORT就是这么做的,论文中提到

We overcome this issue by replacing the association metric with a more informed metric that combines motion and appearance information.In particular,we apply a convolutional neural network (CNN) that has been trained to discriminate pedestrians on a large-scale person re-identification dataset.

很显然,DeepSORT中采用了一个简单(运算量不大) 的CNN来提取被检测物体(检测框物体中)的外观特征(低维向量表示),在每次(每帧)检测+追踪后,进行一次物体外观特征的提取并保存。

后面每执行一步时,都要执行一次当前帧被检测物体外观特征之前存储的外观特征相似度计算, 这个相似度将作为一个重要的判别依据(不是唯一的,因为作者说是将运动特征外观特征结合作为判别依据,这个运动特征就是SORT中卡尔曼滤波做的事)。

那么这个小型的CNN网络长什么样子呢?论文中给出了结构表,如下

那么这个网络最后输出的是一个128维的向量。有关残差网络和上图表中残差模块的结构就不多说,很简单。

值得关注的是,由于DeepSORT主要被用来做行人追踪的,那么输入的大小为128(高)x 64(宽)的矩形框。如果你需要做其他物体追踪,可能要把网络模型的输入进行修改。

实现该网络结构的代码如下:

class Net(nn.Module):
    def __init__(self, num_classes=751 ,reid=False):
        super(Net,self).__init__()
        # 3 128 64
        self.conv = nn.Sequential(
            nn.Conv2d(3,64,3,stride=1,padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            # nn.Conv2d(32,32,3,stride=1,padding=1),
            # nn.BatchNorm2d(32),
            # nn.ReLU(inplace=True),
            nn.MaxPool2d(3,2,padding=1),
        )
        # 32 64 32
        self.layer1 = make_layers(64,64,2,False)
        # 32 64 32
        self.layer2 = make_layers(64,128,2,True)
        # 64 32 16
        self.layer3 = make_layers(128,256,2,True)
        # 128 16 8
        self.layer4 = make_layers(256,512,2,True)
        # 256 8 4
        self.avgpool = nn.AvgPool2d((8,4),1)
        # 256 1 1 
        self.reid = reid
        self.classifier = nn.Sequential(
            nn.Linear(512, 256),
            nn.BatchNorm1d(256),
            nn.ReLU(inplace=True),
            nn.Dropout(),
            nn.Linear(256, num_classes),
        )
    
    def forward(self, x):
        x = self.conv(x)
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)
        x = self.avgpool(x)
        x = x.view(x.size(0),-1)
        # B x 128
        if self.reid:
            x = x.div(x.norm(p=2,dim=1,keepdim=True))
            return x
        # classifier
        x = self.classifier(x)
        return x

可以看出,网络的输出为分类数,并不是128。那么后面想要提取图像的特征,只需要在使用过程中,将最后一层classifier忽略即可(128维向量为输入到classifier前的特征图)。

DeepSort中的卡尔曼滤波

在SORT深度解析时提到过,SORT中的卡尔曼滤波算法使用的状态是一个7维的向量。即

在DeepSORT中,使用的状态是一个8维的向量

相较于SORT中的状态,多了一个长宽比(aspect ratio)变化率。这是合情合理的,毕竟像SORT中假设物体检测框的长宽比是固定的,实际过程中,随着镜头移动或者物体与相机的相对运动,物体的长宽比也是会发生变化的。

同时,DeepSORT对追踪的初始化、新生与消失进行了设定。

  • 初始化:如果一个检测没有和之前记录的track相关联,那么从该检测开始,初始化一个新的目标(并不是新生)
  • 新生:如果一个目标被初始化后,且在前三帧中均被正常的捕捉关联成功,那么该物体产生一个新的track,否则将被删除。
  • 消失:如果超过了设定的最大保存时间(原文中叫做predefined maximum age)没有被关联到的话,那么说明这个物体离开了视频画面,该物体的信息(记录的外观特征和行为特征)将会被删除。

DeepSort中的分配问题

  • 惯例中(类似SORT),解决分配问题使用的是匈牙利算法(仅使用运动特征计算代价矩阵),该算法解决了由滤波算法预测的位置与检测出来的位置间的匹配。

  • DeepSORT中,作者结合了外观特征(由小型CNN提取的128维向量)和运动特征(卡尔曼滤波预测的结果)来计算代价矩阵,从而根据该代价矩阵使用匈牙利算法进行目标的匹配。

1. 运动(motion)特征

作者使用了马氏距离,用来衡量预测到的卡尔曼滤波状态和新获得的测量值(检测框)之间的距离。

公式1

公式1

上述公式中 (yi,Si) 表示第i个追踪分布(卡尔曼滤波分布)在测量空间上的投影, yi 为均值, Si 为协方差。因为要和测量值(检测框) 进行距离测算,所以必须转到同一空间分布中才能进行。

马氏距离通过测量卡尔曼滤波器的追踪位置均值(mean track location)之间的标准差检测框来计算状态估计间的不确定性, 即 d1(i,j) 为第i个追踪分布和第j个检测框之间的马氏距离(不确定度)。

值得注意的是,这里的两个符号含义分别为

  • i:追踪的序号
  • j:检测框的序号

i,j的含义将在后面的解析中仍然出现。

使用对马氏距离设定一定的阈值,可以排除那些没有关联的目标。文章中给出的阈值是

the Mahalanobis distance at a 95% confidence interval computed from the inverse χ2 distribution.

就是倒卡方分布计算出来的95%置信区间作为阈值。

有关马氏距离的实现,定义在Tracker类中可以获得,代码如下:

    def gating_distance(self, mean, covariance, measurements,
                        only_position=False):
        """Compute gating distance between state distribution and measurements.

        A suitable distance threshold can be obtained from `chi2inv95`. If
        `only_position` is False, the chi-square distribution has 4 degrees of
        freedom, otherwise 2.

        Parameters
        ----------
        mean : ndarray
            Mean vector over the state distribution (8 dimensional).
        covariance : ndarray
            Covariance of the state distribution (8x8 dimensional).
        measurements : ndarray
            An Nx4 dimensional matrix of N measurements, each in
            format (x, y, a, h) where (x, y) is the bounding box center
            position, a the aspect ratio, and h the height.
        only_position : Optional[bool]
            If True, distance computation is done with respect to the bounding
            box center position only.

        Returns
        -------
        ndarray
            Returns an array of length N, where the i-th element contains the
            squared Mahalanobis distance between (mean, covariance) and
            `measurements[i]`.

        """
        mean, covariance = self.project(mean, covariance)
        if only_position:
            mean, covariance = mean[:2], covariance[:2, :2]
            measurements = measurements[:, :2]

        cholesky_factor = np.linalg.cholesky(covariance)
        d = measurements - mean
        z = scipy.linalg.solve_triangular(
            cholesky_factor, d.T, lower=True, check_finite=False,
            overwrite_b=True)
        squared_maha = np.sum(z * z, axis=0)
        return squared_maha

显然当目标运动过程中的不确定度比较低(马氏距离小)的时候(也就是满足卡尔曼滤波算法的假设,即所有物体的运动具有一定规律,且没有什么遮挡),那么基于motion特征的方法,即上面提到的方法(可是视为改进的SORT)自然有效。

但是实际情况哪有那么理想,所有仅靠motion特征是不行的了,需要用到appearance特征来弥补不足。

2. 外观(appearance)特征

前面我们提到了外观特征提取网络——小型的残差网络。该网络接受reshape的检测框(大小为128x64,针对行人的)内物体作为输入,返回128维度的向量表示。

对于每个检测框(编号为j)内物体djd_j ,其128维度的向量设为rjr_j ,该向量的模长为1,即 rj||r_j||=1 。这个应该是经过了一个softmax层的原因。

接着作者对每个目标k创建了一个gallery,该gallery用来存储该目标在不同帧中的外观特征(128维向量),论文中用 RkR_k 表示。

注意,这里的k的含义是追踪的目标k,也就是object-in-track的序号。为了区分i和k,我画了个示意图,如下。

作者原论文是这么提到的

这里的 RkR_k 就是gallery,作者限定了 LkL_k的大小,它最大不超过100,即最多只能存储目标k当前时刻前100帧中的目标外观特征。这里的i表示的就是前面提到的追踪(track)的序号。(注意和object-in-track区分)

接着在某一时刻,作者获得出检测框(编号为j)的外观特征,记作 rjr_j 。然后求解所有已知的gallery中的外观特征与获得的检测框(编号为j)的外观特征最小余弦距离。即(为什么不是和每个gallery中的最后一个特征进行比较?)

公式2

接着作者对最小余弦距离设定了阈值,来区分关联是否合理,如下

3. 运动(motion)特征与外观(appearance)特征的融合

motion特征和appearance特征是相辅相成的。在DeepSORT中,motion特征(由马氏距离计算获得)提供了物体定位的可能信息,这在短期预测中非常有效。

appearance特征(由余弦距离计算获得)可以在目标被长期遮挡后,恢复目标的ID编号,减少ID切换次数。

为了结合两个特征,作者做了一个简单的加权运算。也就是

公式3

这里的 d1(i,j)d^1(i,j) 为马氏距离, d2(i,j)d^2(i,j) 为余弦距离。λ\lambda 为权重系数。所以当 λ=1\lambda = 1  时,那么就是改进版的SORT(上文中的定义), λ=0 时,仅仅依靠外观特征进行匹配也是可以进行追踪的。

最后,作者设定了如何判断关联是否匹配的判别总阈值,作者提到

where we call an association admissible if it is within the gating region of both metrics

公式4

作者将上面提到的两个阈值(分别为马氏距离和余弦距离的阈值)综合到了一起,联合判断该某一关联(association)是否合理可行的(admissible)

4 更多匹配的细节--级联匹配

ffff.png

论文中作者提到了Matching Cascade,该算法流程的伪代码如下:

输入:该算法接受三个输入,分别为

  • 追踪的索引集合π,i[1,N]\pi,i\in[1,N],i在前面已经讲过了,是追踪的序号
  • 当前帧检测框索引的集合 D , j∈[1,M] ,j在前面已经讲过了,是检测框的序号
  • 最大保留时长(Maximum age)AmaxA_max

步骤1:根据上面图名为公式3的公式,计算联合代价矩阵

步骤2:根据上面图名为公式4的公式,计算gate矩阵(即门控矩阵,用于限制代价矩阵中过大的值);

步骤3:初始化匹配列表 M ,为空

步骤4:初始化非匹配列表 U ,将 D 赋予

步骤5:循环

  • 按照给定的age选择track
  • (使用匈牙利算法)计算最小代价匹配时的i,j
  • 将满足合适条件的i,j赋值给匹配列表MM ,保存
  • 重新更新非匹配列表UU

步骤6:循环结束,匹配完成

返回匹配列表 MM非匹配列表UU

代码实现如下:

def min_cost_matching(
        distance_metric, max_distance, tracks, detections, track_indices=None,
        detection_indices=None):
    """Solve linear assignment problem.

    Parameters
    ----------
    distance_metric : Callable[List[Track], List[Detection], List[int], List[int]) -> ndarray
        The distance metric is given a list of tracks and detections as well as
        a list of N track indices and M detection indices. The metric should
        return the NxM dimensional cost matrix, where element (i, j) is the
        association cost between the i-th track in the given track indices and
        the j-th detection in the given detection_indices.
    max_distance : float
        Gating threshold. Associations with cost larger than this value are
        disregarded.
    tracks : List[track.Track]
        A list of predicted tracks at the current time step.
    detections : List[detection.Detection]
        A list of detections at the current time step.
    track_indices : List[int]
        List of track indices that maps rows in `cost_matrix` to tracks in
        `tracks` (see description above).
    detection_indices : List[int]
        List of detection indices that maps columns in `cost_matrix` to
        detections in `detections` (see description above).

    Returns
    -------
    (List[(int, int)], List[int], List[int])
        Returns a tuple with the following three entries:
        * A list of matched track and detection indices.
        * A list of unmatched track indices.
        * A list of unmatched detection indices.

    """
    if track_indices is None:
        track_indices = np.arange(len(tracks))
    if detection_indices is None:
        detection_indices = np.arange(len(detections))

    if len(detection_indices) == 0 or len(track_indices) == 0:
        return [], track_indices, detection_indices  # Nothing to match.

    cost_matrix = distance_metric(
        tracks, detections, track_indices, detection_indices)
    cost_matrix[cost_matrix > max_distance] = max_distance + 1e-5

    row_indices, col_indices = linear_assignment(cost_matrix)

    matches, unmatched_tracks, unmatched_detections = [], [], []
    for col, detection_idx in enumerate(detection_indices):
        if col not in col_indices:
            unmatched_detections.append(detection_idx)
    for row, track_idx in enumerate(track_indices):
        if row not in row_indices:
            unmatched_tracks.append(track_idx)
    for row, col in zip(row_indices, col_indices):
        track_idx = track_indices[row]
        detection_idx = detection_indices[col]
        if cost_matrix[row, col] > max_distance:
            unmatched_tracks.append(track_idx)
            unmatched_detections.append(detection_idx)
        else:
            matches.append((track_idx, detection_idx))
    return matches, unmatched_tracks, unmatched_detections

和上面的伪代码一一对应,很清晰。

级联匹配Cascade Match结束后,作者提到

In a final matching stage, we run intersection over union association as proposed in the original SORT algorithm [12] on the set of unconfirmed and unmatched tracks of age n = 1.This helps to to account for sudden appearance changes, e.g., due to partial occlusion with static scene geometry, and to increase robustness against erroneous initialization.

也就是对刚初始化的目标等无法确认(匹配)的追踪,因为没有之前的运动信息和外观信息,这里我们采用IOU匹配关联进行追踪!(对应总体chart flow中卡尔曼滤波预测中,未确定匹配后数据直接流向IOU Match的部分)的代码实现如下:

        # Associate remaining tracks together with unconfirmed tracks using IOU.
        iou_track_candidates = unconfirmed_tracks + [
            k for k in unmatched_tracks_a if
            self.tracks[k].time_since_update == 1]
        unmatched_tracks_a = [
            k for k in unmatched_tracks_a if
            self.tracks[k].time_since_update != 1]
        matches_b, unmatched_tracks_b, unmatched_detections = \
            linear_assignment.min_cost_matching(
                iou_matching.iou_cost, self.max_iou_distance, self.tracks,
                detections, iou_track_candidates, unmatched_detections)

至此,有关DeepSORT就讲解结束了。(就搬运结束了.jpg)