SORT+DeepSORT
1. 简介
- SORT: Simple Online and Realtime Tracking
- MOT:Multiple Object Tracking (是一个数据关联问题)
- BBox:Bounding Box
- ReID:Re-Identification
- MOTA:Multi-object tracking accuracy
- MOTP:Multi-object tracking precision
- FP:number of false detections (误检数)
- FN:number of missed detections (漏检数)
- 奥卡姆剃刀定律(Occam’s Razor) :“如无必要,勿增实体”,即“简单有效原理”
1.1 评价指标
- ( o i , h j ) (o_i, h_j) (oi,hj):是对象 o i o_i oi与跟踪假设 ( h j ) (h_j) (hj)间的一个映射(匹配),且它们的距离不超过阈值 T T T,所有的映射应当确保所有的距离和最小
- c t c_t ct:在时间 t t t ,找到的匹配数量
- d t i d_t^i dti:在时间 t t t,对象 o i o_i oi与它的跟踪假设间的距离 (距离计算方法:区域(框)跟踪器,距离可用两者的重叠区域来度量;点跟踪器,距离可用两者中心点的欧氏距离来度量)
- f p t fp_t fpt:在时间 t t t,未被匹配的跟踪假设数量(false positive)
- m t m_t mt:在时间 t t t,未被匹配的对象数量(missed objects)
- g t g_t gt:在时间 t t t,找到的对象数量
- m m e t mme_t mmet:在时间 t t t,ID跳变数量
- MOTP:跟踪定位精度指标
M O T P = Σ i , t d t i Σ t c t MOTP = \frac{\Sigma_{i,t} d_t^i}{\Sigma_t c_t} MOTP=ΣtctΣi,tdti - MOTA:综合了漏检率,误检率,以及 ID 跳变率
M O T A = 1 − Σ t ( m t + f p t + m m e t ) Σ t g t MOTA = 1 - \frac{\Sigma_t(m_t + fp_t + mme_t)}{\Sigma_t g_t} MOTA=1−ΣtgtΣt(mt+fpt+mmet)
1.2 SORT功能
- 仅使用Kalman Filter和Hungarian algorithm获得较好的准确率和实时性
- 在图像空间执行卡尔曼滤波
- 使用匈牙利算法进行相邻帧间的数据关联
- 遵循奥卡姆剃刀定律,未考虑以下因素:
- 外观特征
- 短期和长期的遮挡
- 其速度模型仅考虑相邻帧间的预测,而未考虑对象重识别(ReID)
- 不足之外
- 仅状态估计不确定性低时效果才比较好 (由所使用的关联指标决定的)
1.3 DeepSORT功能
- 方法:
- 在SORT的基础上,集成了外观信息(appearance information)以改善SORT的性能
- 在可视的外观空间中,通过最近邻查询方法(nearest neighbor queries)来建立测量到跟踪的关联(measurement-to-track associations)
- 效果:
- 可跟踪长时间遮挡的对象
- 有效地减少了ID跳变
1.4 实现方案
- 视频中不同时刻的同一个人,位置发生了变化,那么是如何关联上的呢? 答案就是:
- 匈牙利算法
- 卡尔曼滤波。
- 卡尔曼滤波可以基于目标前一时刻的位置,来预测当前时刻的位置,并且可以比传感器(在目标跟踪中即目标检测器,比如Yolo等)更准确的估计目标的位置。
- 匈牙利算法可以告诉我们当前帧的某个目标,是否与前一帧的某个目标相同。
2. 卡尔曼滤波(Kalman Filter: 运动预测)
- 作用:基于传感器的测量值来更新预测值,以达到更精确的估计。
- 举例说明:假设我们要跟踪小车的位置变化,如下图所示,蓝色的分布是卡尔曼滤波预测值,棕色的分布是传感器的测量值,灰色的分布就是基于测量值更新后的最优估计值。
- 在目标跟踪中,需要估计track的以下两个状态:
- 均值(Mean):表示目标的位置信息,由BBox的中心坐标 ( c x , c y ) (c_x, c_y) (cx,cy),宽高比r,高h,以及各自的速度变化值组成,由8维向量表示为 x = [ c x , c y , r , h , v x , v y , v r , v h ] x = [c_x, c_y, r, h, v_x, v_y, v_r, v_h] x=[cx,cy,r,h,vx,vy,vr,vh],各个速度值初始化为0。
- 协方差(Covariance ):表示目标位置信息的不确定性,由8x8的对角矩阵表示,矩阵中数字越大则表明不确定性越大,可以以任意值初始化。
- 卡尔曼滤波分为两个阶段:
- (1) 预测track在下一时刻的位置
- (2) 基于detection来更新预测的位置。
3. 匈牙利算法(Hungarian Algorithm:数据关联)
3.1 分配问题(Assignment Problem)
- 假设有N个人和N个任务,每个任务可以任意分配给不同的人,已知每个人完成每个任务要花费的代价不尽相同,那么如何分配可以使得总的代价最小。
- 举个例子,假设现在有3个任务,要分别分配给3个人,每个人完成各个任务所需代价矩阵(cost matrix)如下所示(这个代价可以是金钱、时间等等)
P e r s o n Task1 Task2 Task3 P e r s o n 1 15 40 45 P e r s o n 2 20 60 35 P e r s o n 3 20 40 25 \begin{array}{c|lcr} Person & \text{Task1} & \text{Task2} & \text{Task3} \\ \hline Person1 & 15 & 40 & 45 \\ Person2 & 20 & 60 & 35 \\ Person3 & 20 & 40 & 25 \end{array} PersonPerson1Person2Person3Task1152020Task2406040Task3453525 - 怎样才能找到一个最优分配,使得完成所有任务花费的代价最小呢?
3.2 匈牙利算法(又叫KM算法)
- 匈牙利算法:就是用来解决分配问题的一种方法,它基于定理:如果代价矩阵的某一行或某一列同时加上或减去某个数,则这个新的代价矩阵的最优分配仍然是原代价矩阵的最优分配
3.2.1 算法步骤(假设矩阵为NxN方阵)
- 对于矩阵的每一行,减去其中最小的元素
- 对于矩阵的每一列,减去其中最小的元素
- 用最少的水平线或垂直线覆盖矩阵中所有的0
- 如果线的数量等于N,则找到了最优分配,算法结束,否则进入步骤5
- 找到没有被任何线覆盖的最小元素,每个没被线覆盖的行减去这个元素,每个被线覆盖的列加上这个元素,返回步骤3
-
step1 每一行最小的元素分别为15、20、20,减去得到:
P e r s o n Task1 Task2 Task3 P e r s o n 1 0 25 30 P e r s o n 2 0 40 15 P e r s o n 3 0 20 5 \begin{array}{c|lcr} Person & \text{Task1} & \text{Task2} & \text{Task3} \\ \hline Person1 & 0 & 25 & 30 \\ Person2 & 0 & 40 & 15 \\ Person3 & 0 & 20 & 5 \end{array} PersonPerson1Person2Person3Task1000Task2254020Task330155 -
step2 每一列最小的元素分别为0、20、5,减去得到:
P e r s o n Task1 Task2 Task3 P e r s o n 1 0 5 25 P e r s o n 2 0 20 10 P e r s o n 3 0 0 0 \begin{array}{c|lcr} Person & \text{Task1} & \text{Task2} & \text{Task3} \\ \hline Person1 & 0 & 5 & 25 \\ Person2 & 0 & 20 & 10 \\ Person3 & 0 & 0 & 0 \end{array} PersonPerson1Person2Person3Task1000Task25200Task325100 -
step3 用最少的水平线或垂直线覆盖所有的0,得到:
-
step4 线的数量为2,小于3,进入下一步
-
step5 现在没被覆盖的最小元素是5,没被覆盖的行(第一和第二行)减去5,得到:
P e r s o n Task1 Task2 Task3 P e r s o n 1 − 5 0 20 P e r s o n 2 − 5 15 5 P e r s o n 3 0 0 0 \begin{array}{c|lcr} Person & \text{Task1} & \text{Task2} & \text{Task3} \\ \hline Person1 & -5 & 0 & 20 \\ Person2 & -5 & 15 & 5 \\ Person3 & 0 & 0 & 0 \end{array} PersonPerson1Person2Person3Task1−5−50Task20150Task32050
被覆盖的列(第一列)加上5,得到:
P e r s o n Task1 Task2 Task3 P e r s o n 1 0 0 20 P e r s o n 2 0 15 5 P e r s o n 3 5 0 0 \begin{array}{c|lcr} Person & \text{Task1} & \text{Task2} & \text{Task3} \\ \hline Person1 & 0 & 0 & 20 \\ Person2 & 0 & 15 & 5 \\ Person3 & 5 & 0 & 0 \end{array} PersonPerson1Person2Person3Task1005Task20150Task32050 -
跳转到step3,用最少的水平线或垂直线覆盖所有的0,得到:
-
step4:线的数量为3,满足条件,算法结束。显然,将任务3分配给第3个人, 任务2分配给第1个人、任务1分配给第2个人时,总的代价最小(0+0+0=0):
-
所以原矩阵的最小总代价为(40+20+25=85):
3.2.2 程序实现
- sklearn里的linear_assignment()函数以及scipy里的linear_sum_assignment()函数都实现了匈牙利算法,两者的返回值的形式不同:
import numpy as np
from sklearn.utils.linear_assignment_ import linear_assignment
from scipy.optimize import linear_sum_assignment
cost_matrix = np.array([
[15,40,45],
[20,60,35],
[20,40,25]
])
matches = linear_assignment(cost_matrix)
print('sklearn API result:\n', matches)
matches = linear_sum_assignment(cost_matrix)
print('scipy API result:\n', matches)
"""Outputs
sklearn API result:
[[0 1]
[1 0]
[2 2]]
scipy API result:
(array([0, 1, 2], dtype=int64), array([1, 0, 2], dtype=int64))
"""
3.2.3 KM算法在DeepSORT中的应用
- 在DeepSORT中,匈牙利算法用来将前一帧中的跟踪框tracks与当前帧中的检测框detections进行关联,通过外观信息(appearance information)和马氏距离(Mahalanobis distance),或者IOU来计算代价矩阵
# linear_assignment.py
def min_cost_matching(distance_metric, max_distance, tracks, detections,
track_indices=None, detection_indices=None):
...
# 计算代价矩阵
cost_matrix = distance_metric(tracks, detections, track_indices, detection_indices)
cost_matrix[cost_matrix > max_distance] = max_distance + 1e-5
# 执行匈牙利算法,得到匹配成功的索引对,行索引为tracks的索引,列索引为detections的索引
row_indices, col_indices = linear_assignment(cost_matrix)
matches, unmatched_tracks, unmatched_detections = [], [], []
# 找出未匹配的detections
for col, detection_idx in enumerate(detection_indices):
if col not in col_indices:
unmatched_detections.append(detection_idx)
# 找出未匹配的tracks
for row, track_idx in enumerate(track_indices):
if row not in row_indices:
unmatched_tracks.append(track_idx)
# 遍历匹配的(track, detection)索引对
for row, col in zip(row_indices, col_indices):
track_idx = track_indices[row]
detection_idx = detection_indices[col]
# 如果相应的cost大于阈值max_distance,也视为未匹配成功
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
4. SORT
- 目标:相邻帧间的预测和关联 (frame-to-frame prediction and association)
- 速度和准确度同时提高
4.1 估计模型
- 使用独立于其他对象和相机运动的线性恒速模型来近似每个对象的帧间位移。 每个目标的状态建模为:
X = [ u , v , s , r , u ˙ , v ˙ , s ˙ ] T X= [u, v, s, r, \dot u, \dot v, \dot s]^T X=[u,v,s,r,u˙,v˙,s˙]T - u u u:对象中心的水平像素坐标
- v v v:对象中心的垂直像素坐标
- s s s:对象BBox的尺度/面积
- r r r:对象BBox的宽高比
- u ˙ , v ˙ , s ˙ \dot u, \dot v, \dot s u˙,v˙,s˙:分别表示u,v,s的速度,通过Kalman Filter求解
- 若一个当前帧中的检测对象与已有的一个目标相关联,则此检测到的BBox用于更新此目标的状态
4.2 数据关联
- 若一个检测BBox与已有目标的 I O U IOU IOU小于 I O U m i n IOU_{min} IOUmin时,则拒绝把此检测BBox分配给此目标
- 分配成本矩阵:每个检测BBox与来自现有目标的所有预测BBox之间的 I O U IOU IOU 距离。
- 最终分配通过匈牙利算法(Hungarian algorithm.)进行求解
5. DeepSORT
- 在SORT的基础上,集成了外观信息(appearance information)以改善SORT的性能
5.1 跟踪处理和状态估计
- 每个目标的状态建模为:
X = [ u , v , γ , h , x ˙ , y ˙ , γ ˙ , h ˙ ] X=[u, v, \gamma, h, \dot x, \dot y, \dot \gamma, \dot h ] X=[u,v,γ,h,x˙,y˙,γ˙,h˙] - ( u , v ) (u, v) (u,v):BBox中心的像素坐标
- γ \gamma γ:宽高比
- h h h:BBox的高度
- ( x ˙ , y ˙ , γ ˙ , h ˙ ) (\dot x, \dot y, \dot \gamma, \dot h) (x˙,y˙,γ˙,h˙):各个分量在图像坐标中的速度
- 为未找到关联的检测BBox新建一个轨迹(track),若开始的连续3帧中有一帧未被关联,则删除此track
- 对于一个成功建立的track,若连续未被关联的帧数达到预定义的 A m a x A_{max} Amax,则删除此track
5.2 分配问题
- 解决预测的卡尔曼状态与新到达的测量值BBox之间的关联的传统方法是构建一个可以使用匈牙利算法求解的分配问题。
- 在分配问题求解时,集成了运动和外观信息,而SORT中只使用了 I O U IOU IOU距离
5.2.1 运动信息(motion information)
- 运动信息:使用预测的卡尔曼状态与新到达的测量值之间的马氏距离
d ( 1 ) ( i , j ) = ( d j − y i ) T S i − 1 ( d j − y i ) ( 1 ) d^{(1)}(i,j) = (d_j - y_i)^T S_i^{-1}(d_j-y_i) \qquad (1) d(1)(i,j)=(dj−yi)TSi−1(dj−yi)(1) - d j d_j dj:第 j j j 个边界框检测
- ( y i , S i ) (y_i, S_i) (yi,Si):第 i i i个轨迹分布到测量空间的投影
- 马氏距离:通过测量检测BBox距离平均轨迹位置有多少标准偏差来考虑状态估计不确定性。
b i , j ( 1 ) = B i n a r y [ d ( i , j ) ( 1 ) ≤ t ( 1 ) ] ( 2 ) b_{i,j}^{(1)} = Binary [ d^{(1)}_{(i,j)} \le t^{(1)} ] \qquad (2) bi,j(1)=Binary[d(i,j)(1)≤t(1)](2) - 若第 i i i 个轨道和第 j j j 个检测之间的关联是可接受的,则 b i , j ( 1 ) b_{i,j}^{(1)} bi,j(1)为1。且 t ( 1 ) = 9.4877 t^{(1)} = 9.4877 t(1)=9.4877
5.2.2 外观信息(appearance information)
- 外观描述符 r j r_j rj: 约束 ∥ r j ∥ \parallel r_j \parallel ∥rj∥ = 1
- 外观空间 R i = { r k ( i ) } k = 1 L k L k = 100 \mathcal R_i = \{r_k^{(i)}\}_{k=1}^{L_k} \quad L_k = 100 Ri={rk(i)}k=1LkLk=100:每个轨道 k k k 有 L k L_k Lk 个相关的外观描述符
- 外观信息:在外观空间中,使用第
i
i
i个轨迹与第
j
j
j个检测BBox间的最小余弦距离
d ( 2 ) ( i , j ) = m i n { 1 − r j T r k ( i ) ∣ r k ( i ) ∈ R i } ( 3 ) d^{(2)}(i,j) = min \{1 - r_j^T r_k^{(i)} \quad | \quad r_k^{(i)} \in \mathcal R_i \} \quad (3) d(2)(i,j)=min{1−rjTrk(i)∣rk(i)∈Ri}(3)
b i , j ( 2 ) = B i n a r y [ d ( 2 ) ( i , j ) ≤ t ( 2 ) ] ( 4 ) b_{i,j}^{(2)} = Binary [ d^{(2)}(i,j) \le t^{(2)} ] \qquad (4) bi,j(2)=Binary[d(2)(i,j)≤t(2)](4) - 使用一个预训练的CNN计算BBox的外观描述符 r j r_j rj
5.2.3 组合运动和外观信息
- 马氏距离:提供了基于运动的可能对象位置信息,这对于短期预测特别有用
- 余弦距离:考虑的外观信息对于在长期遮挡后恢复身份特别有用,此时运动的判别力较低
c i , j = λ d ( 1 ) ( i , j ) + ( 1 − λ ) d ( 2 ) ( i , j ) ( 5 ) c_{i,j} = \lambda d^{(1)}(i,j) + (1-\lambda)d^{(2)}(i,j) \qquad (5) ci,j=λd(1)(i,j)+(1−λ)d(2)(i,j)(5) - 如果关联在两个指标的门控区域内,我们称关联为可接受
b i , j = ∏ m = 1 2 b i , j ( m ) ( 6 ) b_{i,j} = \prod_{m=1}^2 b_{i,j}^{(m)} \qquad (6) bi,j=m=1∏2bi,j(m)(6)
5.2.4 级联匹配
- Line 7:求解 T n T_n Tn中的轨迹 与 u \mathcal u u中的检测BBox之间的线性分配。
5.3 DeepSORT工作流程
- DeepSORT对每一帧的处理流程如下:
- 检测器得到BBox → 生成Detections → 卡尔曼滤波预测→ 使用匈牙利算法将预测后的tracks和当前帧中的detecions进行匹配(级联匹配和IOU匹配) → 卡尔曼滤波更新
- 示例:
- Frame 0:检测器检测到了5个detections,当前没有任何tracks,将这5个detections初始化为5个tracks
- Frame 1:检测器又检测到了5个detections,对于Frame 0中的tracks,先进行预测得到新的tracks,然后使用匈牙利算法将新的tracks与detections进行匹配,得到(track, detection)匹配对,最后用每对中的detection更新对应的track
5.3.1 检测
- 使用Yolo作为检测器,检测当前帧中的bbox:
# demo_yolo3_deepsort.py
def detect(self):
while self.vdo.grab():
...
bbox_xcycwh, cls_conf, cls_ids = self.yolo3(im) # 检测到的bbox[cx,cy,w,h],置信度,类别id
if bbox_xcycwh is not None:
# 筛选出人的类别
mask = cls_ids == 0
bbox_xcycwh = bbox_xcycwh[mask]
bbox_xcycwh[:, 3:] *= 1.2
cls_conf = cls_conf[mask]
...
5.3.2 生成Detections
- 将检测到的BBox转换成Detections:
# deep_sort.py
def update(self, bbox_xywh, confidences, ori_img):
self.height, self.width = ori_img.shape[:2]
# 提取每个bbox的feature
features = self._get_features(bbox_xywh, ori_img)
# [cx,cy,w,h] -> [x1,y1,w,h]
bbox_tlwh = self._xywh_to_tlwh(bbox_xywh)
# 过滤掉置信度小于self.min_confidence的bbox,生成detections
detections = [Detection(bbox_tlwh[i], conf, features[i]) for i,conf in enumerate(confidences) if conf > self.min_confidence]
# NMS (这里self.nms_max_overlap的值为1,即保留了所有的detections)
boxes = np.array([d.tlwh for d in detections])
scores = np.array([d.confidence for d in detections])
indices = non_max_suppression(boxes, self.nms_max_overlap, scores)
detections = [detections[i] for i in indices]
...
5.3.3 卡尔曼滤波预测阶段
- 使用卡尔曼滤波预测前一帧中的tracks在当前帧的状态:
# track.py
def predict(self, kf):
"""Propagate the state distribution to the current time step using a
Kalman filter prediction step.
Parameters
----------
kf: The Kalman filter.
"""
self.mean, self.covariance = kf.predict(self.mean, self.covariance) # 预测
self.age += 1 # 该track自出现以来的总帧数加1
self.time_since_update += 1 # 该track自最近一次更新以来的总帧数加1
5.3.4 匹配
- 首先对基于外观信息的马氏距离计算tracks和detections的代价矩阵,然后相继进行级联匹配和IOU匹配,最后得到当前帧的所有匹配对、未匹配的tracks以及未匹配的detections:
- 计算代价矩阵
def gated_metric(racks, dets, track_indices, detection_indices):
"""
基于外观信息和马氏距离,计算卡尔曼滤波预测的tracks和当前时刻检测到的detections的代价矩阵
"""
features = np.array([dets[i].feature for i in detection_indices])
targets = np.array([tracks[i].track_id for i in track_indices]
# 基于外观信息,计算tracks和detections的余弦距离代价矩阵
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
- 匹配
# tracker.py
def _match(self, detections):
# 区分开confirmed tracks和unconfirmed tracks
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()]
# 对confirmd tracks进行级联匹配
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)
# 对级联匹配中未匹配的tracks和unconfirmed tracks中time_since_update为1的tracks进行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)
# 整合所有的匹配对和未匹配的tracks
matches = matches_a + matches_b
unmatched_tracks = list(set(unmatched_tracks_a + unmatched_tracks_b))
return matches, unmatched_tracks, unmatched_detections
- 级联匹配
def matching_cascade(distance_metric, max_distance, cascade_depth, tracks, detections,
track_indices=None, detection_indices=None):
...
unmatched_detections = detection_indice
matches = []
# 由小到大依次对每个level的tracks做匹配
for level in range(cascade_depth):
# 如果没有detections,退出循环
if len(unmatched_detections) == 0:
break
# 当前level的所有tracks索引
track_indices_l = [k for k in track_indices if
tracks[k].time_since_update == 1 + level]
# 如果当前level没有track,继续
if len(track_indices_l) == 0:
continue
# 匈牙利匹配
matches_l, _, unmatched_detections = min_cost_matching(distance_metric, max_distance, tracks, detections,
track_indices_l, unmatched_detections)
matches += matches_l
unmatched_tracks = list(set(track_indices) - set(k for k, _ in matches))
return matches, unmatched_tracks, unmatched_detections
5.3.5 卡尔曼滤波更新阶段
- 对于每个匹配成功的track,用其对应的detection进行更新,并处理未匹配tracks和detections:
# tracker.py
def update(self, detections):
"""Perform measurement update and track management.
Parameters
----------
detections: List[deep_sort.detection.Detection]
A list of detections at the current time step.
"""
# 得到匹配对、未匹配的tracks、未匹配的dectections
matches, unmatched_tracks, unmatched_detections = self._match(detections)
# 对于每个匹配成功的track,用其对应的detection进行更新
for track_idx, detection_idx in matches:
self.tracks[track_idx].update(self.kf, detections[detection_idx])
# 对于未匹配的成功的track,将其标记为丢失
for track_idx in unmatched_tracks:
self.tracks[track_idx].mark_missed()
# 对于未匹配成功的detection,初始化为新的track
for detection_idx in unmatched_detections:
self._initiate_track(detections[detection_idx])
...