DeepSORT多目標跟蹤算法

DeepSORT 多目標跟蹤算法

整體思路

SORT 算法的思路是將目標檢測算法得到的檢測框與預測的跟蹤框的 iou(交併比)輸入到匈牙利算法中進行線性分配來關聯幀間 Id。而 DeepSORT 算法則是將目標的外觀信息加入到幀間匹配的計算中,這樣在目標被遮擋但後續再次出現的情況下,還能正確匹配 Id,從而減少 Id Switch。

算法思路

狀態估計(state estimation)和軌跡處理(track handing)

狀態估計

用一個 8 維空間表示軌跡在某個時刻的狀態即(u,v,γ,h,x˙,y˙,γ˙,h˙)(u, v, \gamma, h, \dot{x}, \dot{y}, \dot{\gamma}, \dot{h})(u,v)(u,v)表示 bbox 的中心座標,γ\gamma表示寬高比,hh表示高度,最後四個變量代表前四個變量的速度信息。使用一個基於勻速模型和線性觀測模型的標準卡爾曼濾波器進行目標狀態的預測,預測結果爲(u,v,γ,h)(u,v,\gamma , h)。具體關於卡爾曼濾波的解釋,可以見我單獨說明的文章

軌跡處理

針對跟蹤器:
設置計數器,在使用卡爾曼濾波進行預測時遞增,一旦預測的跟蹤結果和目標檢測算法檢測的結果成功匹配,則將該跟蹤器的計數器置零;如果一個跟蹤器在一段時間內一直沒能匹配上檢測的結果,則認爲跟蹤的目標消失,從跟蹤器列表中刪除該跟蹤器。

針對新檢測結果:
當某一幀出現了新的檢測結果時(即與當前跟蹤結果無法匹配的檢測結果),認爲可能出現了新的目標,爲其創建跟蹤器。不過,仍需要觀察,如果連續三幀中新跟蹤器對目標的預測結果都能和檢測結果匹配,那麼確認出現了新的目標軌跡(代碼實現中軌跡state置爲confirmed),否則刪除該跟蹤器。

匹配問題

SORT 算法是將檢測框和跟蹤框的 IOU 情況作爲輸入,採用匈牙利算法(這是一種通過增廣路徑來求二分圖最大匹配的一種方法),輸出檢測框和跟蹤框的匹配結果。而 DeepSORT 爲了避免大量的 Id Switch,同時考慮了運動信息的關聯和目標外觀信息的關聯,使用融合度量的方式計算檢測結果和跟蹤結果的匹配程度。

運動信息的關聯

使用檢測框和跟蹤框之間的馬氏距離來描述運動關聯程度。
d(1)(i,j)=(djyi)TSi1(djyi)d^{(1)}(i, j)=\left(\boldsymbol{d}_{j}-\boldsymbol{y}_{i}\right)^{\mathrm{T}} \boldsymbol{S}_{i}^{-1}\left(\boldsymbol{d}_{j}-\boldsymbol{y}_{i}\right)
其中,djd_j表示第jj個檢測框的位置,yiy_i表示第ii個跟蹤器的預測框位置,SiS_i則表示檢測位置與平均跟蹤位置之間的協方差矩陣。馬氏距離通過計算檢測位置與平均預測位置之間的標準差將狀態測量的不確定性進行了考慮,並且通過逆χ2\chi^2分佈計算得來的95%95\%置信區間對馬氏距離進行閾值化處理,若某一次關聯的馬氏距離小於指定的閾值t(1)t^{(1)},則設置運動狀態關聯成功,實驗中設置閾值爲9.4877。
bi,j(1)=1[d(1)(i,j)t(1)]b_{i, j}^{(1)}=\mathbb{1}\left[d^{(1)}(i, j) \leq t^{(1)}\right]

目標外觀信息的關聯

當運動的不確定性很低的時候,上述的馬氏距離匹配是一個合適的關聯度量方法,但是在圖像空間中使用卡爾曼濾波進行運動狀態估計只是一個比較粗糙的預測。特別是相機存在運動時會使得馬氏距離的關聯方法失效,造成 ID switch 的現象。

因此作者引入了第二種關聯方法,對每一個檢測框djd_j求一個特徵向量rjr_j (通過 REID 的 CNN 網絡計算得到的對應的 128 維特徵向量),限制條件是rj=1||rj||=1。作者對每一個跟蹤目標構建一個gallary,存儲每一個跟蹤目標成功關聯的最近100幀的特徵向量。那麼第二種度量方式就是計算第ii個跟蹤器的最近 100 個成功關聯的特徵集與當前幀第jj個檢測結果的特徵向量間的最小余弦距離。計算公式如下:(注意:軌跡太長,導致外觀發生變化,發生變化後,再使用最小余弦距離作爲度量會出問題,所以在計算距離時,軌跡中的檢測數量不能太多)
d(2)(i,j)=min{1rjTrk(i)rk(i)Ri}d^{(2)}(i, j)=\min \left\{1-r_{j}^{\mathrm{T}} \boldsymbol{r}_{k}^{(i)} | \boldsymbol{r}_{k}^{(i)} \in \mathcal{R}_{i}\right\}
如果上面的距離小於指定的閾值,那麼這個關聯就是成功的。閾值是從單獨的訓練集裏得到的,具體如下。
bi,j(2)=1[d(2)(i,j)t(2)]b_{i, j}^{(2)}=\mathbb{1}\left[d^{(2)}(i, j) \leq t^{(2)}\right]

關聯方式融合

使用兩種度量方式的線性加權作爲最終的度量。
ci,j=λd(1)(i,j)+(1λ)d(2)(i,j)c_{i, j}=\lambda d^{(1)}(i, j)+(1-\lambda) d^{(2)}(i, j)
注意:只有當兩個指標都滿足各自閾值條件的時候才進行融合。距離度量對短期的預測和匹配效果很好,但對於長時間的遮擋的情況,使用外觀特徵的度量比較有效。作者指出,對於存在相機運動的情況,可以設置λ=0\lambda=0。但是,馬氏距離的閾值仍然生效,如果不滿足第一個度量的標準,就不能進入ci,jc_{i,j}的融合階段。

此時,需要考慮的閾值就變爲下式。
bi,j=m=12bi,j(m)b_{i, j}=\prod_{m=1}^{2} b_{i, j}^{(m)}

級聯匹配

一個目標長時間被遮擋之後,卡爾曼濾波預測的不確定性就會大大增加,狀態空間內的可觀察性就會大大降低。假如此時兩個跟蹤器競爭同一個檢測結果的匹配權,往往遮擋時間較長的那條軌跡因爲長時間未更新位置信息,追蹤預測位置的不確定性更大,即協方差會更大,馬氏距離計算時使用了協方差的倒數,因此馬氏距離會更小,因此使得檢測結果更可能和遮擋時間較長的那條軌跡相關聯,這種不理想的效果往往會破壞追蹤的持續性。

簡單理解,假設本來協方差矩陣是一個正態分佈,那麼連續的預測不更新就會導致這個正態分佈的方差越來越大,那麼離均值歐氏距離遠的點可能和之前分佈中離得較近的點獲得同樣的馬氏距離值。

所以,作者使用了級聯匹配來對更加頻繁出現的目標賦予優先權,具體算法如下圖(圖源自論文)。

在這裏插入圖片描述

T表示當前的跟蹤狀態集合,D表示當前的檢測狀態集合。
第一行根據公式5計算融合度量的代價矩陣;
第二行計算融合的閾值;
第三行,初始化已匹配集合爲空集;
第四行初始化未匹配集合U爲檢測集合D;第
五行表示對跟蹤狀態集合從1到最大跟蹤時間Amax,由近到遠循環;
第六行表示根據時間選擇跟蹤的軌跡;
第七行表示計算最小匹配的軌跡的ID即xi,jx_{i,j}
第八行表示將第七步中匹配的ID加入到M中;
第九行表示將上述ID從U中刪除;
第十行表示結束循環;
第十一行表示返回最終匹配集合M和未匹配集合U。

級聯匹配的核心思想就是由小到大對消失時間相同的軌跡進行匹配,這樣首先保證了對最近出現的目標賦予最大的優先權,也解決了上面所述的問題。在匹配的最後階段還對 unconfirmed和age=1的未匹配軌跡進行基於IoU的匹配。這可以緩解因爲表觀突變或者部分遮擋導致的較大變化。

深度特徵提取

網絡結果如下圖。
在這裏插入圖片描述

要求輸入的圖像爲128*64,輸出128維的特徵向量。我在使用Pytorch實現時將上述結構中的池化換成了stride爲2的卷積,輸出隱層換位256維特徵向量,以增大一定參數量的代價試圖獲得更好的結果。此外,參考最近較火的EfficientNet進行優化,但算力要求過大,還在研究中。

由於主要用於行人識別,所以在行人重識別數據集(MARS)上離線訓練模型,學到的參數很適合提取行人特徵,最後輸出256維的歸一化後的特徵。

核心模型結構代碼如下。

class BasicBlock(nn.Module):
    def __init__(self, c_in, c_out, is_downsample=False):
        super(BasicBlock, self).__init__()
        self.is_downsample = is_downsample
        if is_downsample:
            self.conv1 = nn.Conv2d(c_in, c_out, 3, stride=2, padding=1, bias=False)
        else:
            self.conv1 = nn.Conv2d(c_in, c_out, 3, stride=1, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(c_out)
        self.relu = nn.ReLU(True)
        self.conv2 = nn.Conv2d(c_out, c_out, 3, stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(c_out)
        if is_downsample:
            self.downsample = nn.Sequential(
                nn.Conv2d(c_in, c_out, 1, stride=2, bias=False),
                nn.BatchNorm2d(c_out)
            )
        elif c_in != c_out:
            self.downsample = nn.Sequential(
                nn.Conv2d(c_in, c_out, 1, stride=1, bias=False),
                nn.BatchNorm2d(c_out)
            )
            self.is_downsample = True

    def forward(self, x):
        y = self.conv1(x)
        y = self.bn1(y)
        y = self.relu(y)
        y = self.conv2(y)
        y = self.bn2(y)
        if self.is_downsample:
            x = self.downsample(x)
        return F.relu(x.add(y), True)  # 殘差連接


def make_layers(c_in, c_out, repeat_times, is_downsample=False):
    blocks = []
    for i in range(repeat_times):
        if i == 0:
            blocks += [BasicBlock(c_in, c_out, is_downsample=is_downsample), ]
        else:
            blocks += [BasicBlock(c_out, c_out), ]
    return nn.Sequential(*blocks)


class Net(nn.Module):
    def __init__(self, num_classes=1261, reid=False):
        """

        :param num_classes: 分類器層輸出的類別數目
        :param reid: 是否爲reid模式,若爲True,直接返回特徵向量而不做分類
        """
        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.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 256
        if self.reid:
            x = x / x.norm(p=2, dim=1, keepdim=True)  # 張量單位化
            return x
        # 分類器
        x = self.classifier(x)
        return x

使用GPU在MARS數據集訓練50輪的結果如下。

在這裏插入圖片描述

流程描述

如下圖,使用Visio繪製。
在這裏插入圖片描述
相比於SORT,DeepSORT主要更新就是加入了深度特徵提取器和級聯匹配,其餘並沒有太大變化,還是按照SORT那一套進行。

最後,DeepSORT算法封裝的跟蹤器類源碼如下,其中各功能已經詳細備註。

import numpy as np
from . import kalman_filter
from . import linear_assignment
from . import iou_matching
from .track import Track


class Tracker:
    """
    多目標跟蹤器實現
    """

    def __init__(self, metric, max_iou_distance=0.7, max_age=70, n_init=3):
        self.metric = metric
        self.max_iou_distance = max_iou_distance
        self.max_age = max_age
        self.n_init = n_init

        self.kf = kalman_filter.KalmanFilter()
        self.tracks = []
        self._next_id = 1

    def predict(self):
        """
        狀態預測
        """
        for track in self.tracks:
            track.predict(self.kf)

    def update(self, detections):
        """
        狀態更新
        """
        # 級聯匹配
        matches, unmatched_tracks, unmatched_detections = self._match(detections)

        # Update track set.
        for track_idx, detection_idx in matches:
            # 成功匹配的要用檢測結果更新對於track的參數
            # 包括
            #   更新卡爾曼濾波一系列運動變量、命中次數以及重置time_since_update
            #   檢測的深度特徵保存到track的特徵集中
            #   連續命中三幀,將track狀態由tentative改爲confirmed

            self.tracks[track_idx].update(
                self.kf, detections[detection_idx])
        for track_idx in unmatched_tracks:
            # 未成功匹配的track
            #   若未經過confirm則刪除
            #   若已經confirm但連續max_age幀未匹配到檢測結果也刪除
            self.tracks[track_idx].mark_missed()
        for detection_idx in unmatched_detections:
            # 未匹配的檢測,爲其創建新的track
            self._initiate_track(detections[detection_idx])
        self.tracks = [t for t in self.tracks if not t.is_deleted()]

        # Update distance metric.
        # 更新已經確認的track的特徵集
        active_targets = [t.track_id for t in self.tracks if t.is_confirmed()]
        features, targets = [], []
        for track in self.tracks:
            if not track.is_confirmed():
                continue
            features += track.features
            targets += [track.track_id for _ in track.features]
            track.features = []
        self.metric.partial_fit(
            np.asarray(features), np.asarray(targets), active_targets)

    def _match(self, detections):
        """
        跟蹤結果和檢測結果的匹配
        :param detections:
        :return:
        """

        def gated_metric(tracks, dets, track_indices, detection_indices):
            features = np.array([dets[i].feature for i in detection_indices])
            targets = np.array([tracks[i].track_id for i in track_indices])
            cost_matrix = self.metric.distance(features, targets)
            cost_matrix = linear_assignment.gate_cost_matrix(
                self.kf, cost_matrix, tracks, dets, track_indices,
                detection_indices)

            return cost_matrix

        # 將track分爲確認track和未確認track
        confirmed_tracks = [
            i for i, t in enumerate(self.tracks) if t.is_confirmed()]
        unconfirmed_tracks = [
            i for i, t in enumerate(self.tracks) if not t.is_confirmed()]

        # 將確認的track和檢測結果進行級聯匹配(使用外觀特徵)
        matches_a, unmatched_tracks_a, unmatched_detections = linear_assignment.matching_cascade(
            gated_metric, self.metric.matching_threshold, self.max_age,
            self.tracks, detections, confirmed_tracks)

        # 將上一步未成功匹配的track和未確認的track組合到一起形成iou_track_candidates於還沒有匹配結果的檢測結果進行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]
        # 計算兩兩之間的iou,再通過1-iou得到cost matrix
        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)

        matches = matches_a + matches_b  # 組合獲得當前所有匹配結果
        unmatched_tracks = list(set(unmatched_tracks_a + unmatched_tracks_b))
        return matches, unmatched_tracks, unmatched_detections

    def _initiate_track(self, detection):
        """
        初始化新的跟蹤器,對應新的檢測結果
        :param detection:
        :return:
        """
        # 初始化卡爾曼
        mean, covariance = self.kf.initiate(detection.to_xyah())
        # 創建新的跟蹤器
        self.tracks.append(Track(
            mean, covariance, self._next_id, self.n_init, self.max_age,
            detection.feature))
        # id自增
        self._next_id += 1

項目說明

本項目主要分爲三大模塊,deepsort算法模塊(其中又分deep模塊和sort模塊),yolo3檢測模塊,以及web模塊。最終封裝爲一個跟蹤器模塊,用於外部接口調用,該模塊接受一個視頻或者圖片序列。

其中,deepsort算法模塊包含深度外觀特徵提取器的deep模塊(使用Pytorch實現及訓練)以及原始sort跟蹤算法模塊(該模塊部分內容參考SORT論文源碼);yolo3檢測模塊調用封裝好的Pytorch實現的YOLO3算法,做了本部分API的兼容;web模塊則以Django爲框架實現了模型的後端部署,用戶通過網頁提交視頻,後端解析生成跟蹤結果(由於機器限制,目前只返回部分幀的檢測結果,實時生成依賴GPU服務器,個人電腦FPS較低。)

具體演示如下(瀏覽器訪問)。

在這裏插入圖片描述
在這裏插入圖片描述
直接執行腳本也可以生成跟蹤後的視頻文件,如下。

在這裏插入圖片描述

補充說明

具體代碼開源於我的Github,歡迎star或者fork。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章